8 min read

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-prometheus module 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