The Caddyfile Cookbook
You’ve got Caddy running. You know it auto-handles HTTPS. Now you’re staring at a blank Caddyfile wondering how to do the thing you actually need to do.
This is that reference. Bookmark it, keep it open in a tab, steal from it freely.
Each pattern is minimal and working. Adapt the domain names and paths to your setup.
Multi-Site on One Server
Run multiple sites from a single Caddyfile — no virtual host gymnastics required.
app.example.com { reverse_proxy localhost:3000}
files.example.com { root * /srv/files file_server browse}
blog.example.com { reverse_proxy localhost:4321}Each site block is independent. Caddy handles HTTPS for all three automatically.
Reverse Proxy with Custom Headers
Pass the real client IP upstream and strip headers you don’t want leaking to internal services.
app.example.com { reverse_proxy localhost:8080 { header_up X-Real-IP {remote_host} header_up X-Forwarded-Proto {scheme} header_down -X-Powered-By header_down -Server }}header_up adds/sets request headers sent to the backend. header_down with a - prefix strips response headers from what the client sees. Hide the fact you’re running PHP 7.2 from 2019.
Basic Auth on a Specific Path
Protect /admin without locking down the whole site. Users hit the public site normally; /admin prompts for credentials.
example.com { reverse_proxy localhost:3000
basicauth /admin* { # Generate hash: caddy hash-password alice $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG }}Generate the password hash with caddy hash-password --plaintext yourpassword. The * glob means /admin, /admin/settings, /admin/users — all protected.
Redirects and Rewrites
301 Redirect (old slug → new slug)
example.com { redir /old-page /new-page 301 reverse_proxy localhost:3000}www to non-www
www.example.com { redir https://example.com{uri} 301}
example.com { reverse_proxy localhost:3000}Path rewrite (strip prefix before proxying)
example.com { handle /api/* { uri strip_prefix /api reverse_proxy localhost:8080 }}The backend sees /users instead of /api/users. Useful when your app doesn’t know it’s mounted at a subpath.
File Server with Directory Listing
Instant file share — drop files in a folder, browse them in a browser.
files.example.com { root * /srv/files file_server browse}browse enables the directory listing UI. Remove it if you want file serving without listing (404 on directories).
Health Check Endpoint That Bypasses Auth
Your monitoring tool needs to hit /health but you’ve got basicauth on the site. Handle the health check route first.
example.com { handle /health { respond "OK" 200 }
basicauth * { alice $2a$14$Zkx19XLiW6VYouLHR5NmfOFU0z2GTNmpkT/5qqR7hx4IjWJPDhjvG }
reverse_proxy localhost:3000}handle blocks are evaluated in order. The /health route matches first and short-circuits before auth runs.
Rate Limiting (rate_limit plugin)
Requires the caddy-ratelimit plugin. If you built Caddy with xcaddy or used a custom build, add it there. The community Docker image includes it.
api.example.com { rate_limit { zone api_zone { key {remote_host} events 100 window 1m } }
reverse_proxy localhost:8080}100 requests per minute per IP. Exceed it and you get a 429. Tune events and window to your traffic patterns — don’t be surprised when your own health checks trip this.
Structured JSON Logging
Default Caddy logs are human-readable. For log aggregation (Loki, Elasticsearch, whatever you’re running), JSON is easier to parse.
{ log { output file /var/log/caddy/access.log format json }}
example.com { log { output file /var/log/caddy/example.log format json } reverse_proxy localhost:3000}The global log block sets the default. Per-site log blocks let you split logs by domain. Each line is a JSON object — pipe it to jq for sanity checks.
Serving a Single-Page App (SPA)
React, Vue, Svelte — they all need the server to return index.html for unknown paths so client-side routing works.
app.example.com { root * /srv/app try_files {path} /index.html file_server}try_files is Caddy’s equivalent of nginx’s try_files $uri $uri/ /index.html. It tries the exact path first, then falls back to index.html. Static assets (.js, .css) are served directly; routes like /dashboard/settings fall through to the SPA.
Environment Variables in Caddyfile
Stop hardcoding secrets and paths. Caddy reads from the environment with {env.VAR_NAME}.
{env.MY_DOMAIN} { reverse_proxy {env.BACKEND_HOST}:{env.BACKEND_PORT}
basicauth /admin* { {env.ADMIN_USER} {env.ADMIN_HASH} }}Set variables before starting Caddy or in your Docker Compose environment: block. Useful for sharing a Caddyfile across environments without modification.
Wildcard Certificates with DNS Challenge (Cloudflare)
When you need *.example.com covered by a single cert — useful for dynamic subdomains or internal services you don’t want to expose individually.
Requires the Cloudflare DNS provider plugin. Build with xcaddy:
xcaddy build --with github.com/caddy-dns/cloudflareThen in your Caddyfile:
{ acme_dns cloudflare {env.CLOUDFLARE_API_TOKEN}}
*.example.com { tls { dns cloudflare {env.CLOUDFLARE_API_TOKEN} }
@app host app.example.com handle @app { reverse_proxy localhost:3000 }
@files host files.example.com handle @files { root * /srv/files file_server browse }}Named matchers (@app, @files) let you route within a wildcard block based on hostname. One cert, many subdomains, zero port 80 challenges.
One More Thing
Caddy reloads its config without restarting:
caddy reload --config /etc/caddy/CaddyfileNo downtime, no dropped connections. Make a change, reload, verify. It’s genuinely one of the better parts of working with Caddy — your 2 AM self running quick fixes will appreciate not having to restart a process that’s handling live traffic.
Combine these patterns freely. Caddy’s config model composes cleanly — site blocks, matchers, and handlers stack without fighting each other.