Caddy as a reverse proxy: the setup I actually use
A practical Caddyfile configuration for serving multiple static sites and API services from a single VPS, with automatic HTTPS and zero-downtime deploys.
- Infrastructure
- Caddy
- Linux
- Deployment
I’ve been using Caddy as my reverse proxy for about two years. Here’s the Caddyfile setup that I’ve converged on after a lot of trial and error.
Why Caddy over nginx
Nginx is fine. But Caddy’s automatic HTTPS via ACME/Let’s Encrypt means I never think about certificate renewal. The Caddyfile syntax is also dramatically less verbose — a working HTTPS reverse proxy is about 4 lines versus 30 in nginx.
That said, nginx has better documentation, more production battle-hardening, and more available modules. If you’re running a high-traffic service or need advanced config, nginx is probably the right call. For a personal VPS running a handful of sites and services, Caddy is excellent.
The Caddyfile
# Global options
{
email your@email.com
admin off
}
# Static site
lucasnicolas.dev, www.lucasnicolas.dev {
root * /var/www/portfolio/dist
file_server
encode zstd gzip
# Cache static assets aggressively
@assets {
path *.js *.css *.woff2 *.woff *.png *.jpg *.svg *.ico
}
header @assets Cache-Control "public, max-age=31536000, immutable"
# HTML: revalidate
@html {
path *.html
}
header @html Cache-Control "public, max-age=0, must-revalidate"
# Security headers
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
Permissions-Policy "camera=(), microphone=(), geolocation=()"
-Server
}
}
# API service (proxied to local process)
api.lucasnicolas.dev {
reverse_proxy localhost:8080 {
health_uri /health
health_interval 10s
}
encode zstd gzip
}
# Subdirectory for an interactive demo
demo.lucasnicolas.dev {
root * /var/www/demos/graph-visualizer/dist
file_server
encode zstd gzip
try_files {path} /index.html
}
Deploy script
For the static site, my deploy is simple: build locally, rsync to VPS, done. Caddy picks up changes to the dist/ directory automatically since it’s just serving files.
#!/usr/bin/env bash
set -euo pipefail
echo "Building..."
npm run build
echo "Deploying..."
rsync -avz --delete dist/ user@vps:/var/www/portfolio/dist/
echo "Done."
Zero-downtime because there’s no service restart — Caddy continues serving while rsync atomically updates files.
Handling SPA fallback
For single-page apps (like the demo iframes), you need Caddy to serve index.html for any unmatched path. The try_files directive handles this:
try_files {path} /index.html
Without this, navigating directly to /app/some/route returns a 404 instead of letting the SPA router handle it.
Monitoring
I use caddy adapt to validate the Caddyfile before deploying config changes:
caddy adapt --config /etc/caddy/Caddyfile --pretty
For logs, Caddy writes structured JSON by default, which makes it easy to pipe into whatever log aggregation you’re using:
{
log {
output file /var/log/caddy/access.log {
roll_size 50mb
roll_keep 5
}
format json
}
}
What I’d add next
- Rate limiting — Caddy has a rate limiting plugin but I haven’t needed it yet
- Prometheus metrics — the
caddy-prometheusmodule exposes request metrics; would be useful once I have a Grafana instance - Automated deploys — a small webhook endpoint that triggers the rsync on push to
main