Caddy reverse proxy — patterns I actually use
Caddy is my default front-line proxy in the homelab. Showing 5 config patterns that work for me — from basic reverse proxy to HSTS preload + auth gating.

Nginx, Traefik, HAProxy, each has its fans. I use Caddy. Four reasons: auto TLS, readable config, HTTP/3 out of the box, no complications managed by 2% of users. Showing 5 patterns from my setup.
Pattern #1: basic reverse proxy with TLS
The simplest thing I've ever written:
vikunja.kamilkaletka.dev {
reverse_proxy localhost:3456
}That's it. Caddy automatically:
- Fetches a Let's Encrypt cert
- Auto-renews every 60 days
- Forwards HTTP to HTTPS
- Enables HTTP/2 and HTTP/3
Compare with nginx: 30 lines of config, certbot timer, manual renew handling.
Pattern #2: header rewriting + WebSocket
Some apps need custom headers or WebSocket support:
chat.kamilkaletka.dev {
reverse_proxy localhost:8080 {
header_up Host {host}
header_up X-Real-IP {remote_host}
header_up X-Forwarded-For {remote_host}
header_up X-Forwarded-Proto {scheme}
}
}Caddy detects WebSocket itself, no proxy_pass http://upstream/upgrade websocket like nginx. WS upgrade just works.
Pattern #3: path-based routing
One domain, multiple backends:
api.kamilkaletka.dev {
handle_path /v1/* {
reverse_proxy localhost:3001
}
handle_path /v2/* {
reverse_proxy localhost:3002
}
handle_path /admin/* {
reverse_proxy localhost:3003
}
handle {
respond "Not Found" 404
}
}handle_path strips the prefix before forwarding (like nginx rewrite ... break). handle is the else branch.
Pattern #4: forward auth (gating)
When I want to add auth in front of a service that has none:
admin.kamilkaletka.dev {
forward_auth localhost:9000 {
uri /verify
copy_headers X-User-Email X-User-Roles
}
reverse_proxy localhost:3000
}Every request goes to the auth server first (I run Authentik on 9000). If it returns 200 → request flows to the app. If 401/403 → Caddy returns the error, the app never sees the request.
I add X-User-Email headers so the app knows who is logged in.
Pattern #5: HSTS + security headers
Production-ready security headers:
(security_headers) {
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
X-Content-Type-Options "nosniff"
X-Frame-Options "DENY"
Referrer-Policy "strict-origin-when-cross-origin"
Permissions-Policy "geolocation=(), microphone=(), camera=()"
# Remove server headers
-Server
}
}
vikunja.kamilkaletka.dev {
import security_headers
reverse_proxy localhost:3456
}
obsidian-sync.kamilkaletka.dev {
import security_headers
reverse_proxy localhost:5984
}I define the snippet once, import everywhere. Every domain gets the same security headers without repetition.
Trap: bind mount + reload
The most painful trap I learned.
Caddy in Docker, bind mount /etc/caddy/Caddyfile:
volumes:
- ./Caddyfile:/etc/caddy/CaddyfileAfter editing the Caddyfile you do:
# DOESN'T WORK sometimes
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
# ALWAYS WORKS
docker restart caddyReason: bind mount is per-inode. When you edit the file on the host (vim, most editors do temp+rename), Caddy in the container sees the OLD file because it remembers that inode. Container restart = new mount = new inode.
Alternative: edit via docker exec caddy vi, but that's ugly.
Bonus pattern: rate limiting
Caddy has a caddy-ratelimit plugin:
api.kamilkaletka.dev {
rate_limit {
zone api_zone {
key {remote_host}
events 60
window 1m
}
}
reverse_proxy localhost:3001
}60 requests per minute from a single IP. Above → 429. Plugin requires a custom Caddy build (xcaddy), but ~5 minutes of work.
What Caddy doesn't do well
1. Very complex regex routing. Nginx has more tools.
2. Module-based plugin system is weaker. Plugins compile into the binary, no dynamic load. Every plugin = recompile.
3. Performance under extreme load. For > 10k req/s nginx still wins. For a normal homelab (max 100 req/s) it doesn't matter.
Caddy turns 30-line nginx configs into 3-line entries. Auto TLS alone paid for the migration in a week. If you're still on nginx + certbot, give yourself a weekend and switch.