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.