Blog
ENPL

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.

·3 min read
Caddy reverse proxy — patterns I actually use

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/Caddyfile

After editing the Caddyfile you do:

# DOESN'T WORK sometimes
docker exec caddy caddy reload --config /etc/caddy/Caddyfile
 
# ALWAYS WORKS
docker restart caddy

Reason: 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.