Caddy Patterns That Replaced My Nginx Config

I switched from Nginx to Caddy about a year ago and I am never going back. Not because Nginx is bad, but because Caddy eliminates an entire category of problems I used to spend time on.

Automatic TLS

This is the obvious one. Caddy gets certificates from Let’s Encrypt automatically. But the real power is the DNS challenge with wildcard certs:

{
  acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}

*.example.dev {
  tls {
    dns cloudflare {env.CLOUDFLARE_API_TOKEN}
  }

  @app host app.example.dev
  handle @app {
    reverse_proxy app:3000
  }
}

One wildcard cert covers every subdomain. No per-service cert management. No Certificate Transparency log leaking your internal subdomain names. And the DNS challenge means you do not even need port 80 open.

Host Matching

Caddy lets you route by hostname inside a single server block:

*.example.dev {
  @git host git.example.dev
  handle @git {
    reverse_proxy gitea:3000
  }

  @chat host chat.example.dev
  handle @chat {
    reverse_proxy element:80
  }

  handle {
    respond "Not found" 404
  }
}

In Nginx, each of these would be a separate server block with its own SSL configuration. In Caddy, it is one block with named matchers.

VPN-Only Routes

I restrict admin services to my VPN subnet:

@admin host admin.example.dev
handle @admin {
  @blocked not remote_ip 10.0.0.0/24
  respond @blocked "Access Denied" 403
  reverse_proxy admin:3001
}

If the request does not come from the VPN, it gets a 403. Combined with DNS that only resolves through the VPN, this is two layers of protection without any auth middleware.

SPA Fallback With Sub-Paths

Serving a single-page app under a sub-path is surprisingly tricky. The app lives at /app/ but the client-side router handles routes like /app/settings:

handle_path /app* {
  reverse_proxy spa:3000 {
    @notfound status 404
    handle_response @notfound {
      rewrite * /index.html
      reverse_proxy spa:3000
    }
  }
}

handle_path strips the /app prefix before forwarding. If the upstream returns 404, we rewrite to index.html and try again. The SPA router takes over from there.

Gzip and Security Headers

*.example.dev {
  encode gzip

  header {
    Strict-Transport-Security "max-age=31536000; includeSubDomains"
    X-Content-Type-Options "nosniff"
    X-Frame-Options "DENY"
    Referrer-Policy "strict-origin-when-cross-origin"
  }
}

These apply to every subdomain automatically. No copying the same headers into fifteen different location blocks.

Static Files With Custom 404

@landing host example.dev
handle @landing {
  root * /srv/example.dev
  file_server
}

handle_errors {
  @404 expression {err.status_code} == 404
  handle @404 {
    root * /srv
    rewrite * /404.html
    file_server
  }
}

The handle_errors block catches 404s across all routes and serves a custom page. One error handler for the entire server.

WebSocket Proxying

Caddy handles WebSocket upgrades automatically:

reverse_proxy game:3000

That is it. No proxy_http_version 1.1, no proxy_set_header Upgrade, no proxy_set_header Connection. Caddy detects the upgrade request and does the right thing.

Streaming Responses

For Server-Sent Events or long-running responses:

reverse_proxy backend:3000 {
  flush_interval -1
  transport http {
    read_timeout 0
    write_timeout 0
  }
}

flush_interval -1 disables response buffering. The timeout overrides prevent Caddy from killing long-lived connections.

The Caddyfile syntax is not perfect. The matcher system takes some getting used to, and the documentation could be better organized. But the amount of boilerplate it eliminates compared to Nginx is staggering. My entire server config is under 120 lines, and it handles TLS, routing, static files, reverse proxying, WebSockets, and error pages for a dozen services.

← all articles wleeaf.dev →