Install from the official repo

The version in Debian's archive lags a major version. Use the official Cloudsmith repo so you get current Caddy with all the standard plugins:

sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' \
  | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg

curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' \
  | sudo tee /etc/apt/sources.list.d/caddy-stable.list

sudo apt update
sudo apt install caddy

That installs the caddy binary, a caddy system user, a systemd unit (enabled), and a default /etc/caddy/Caddyfile serving a "Caddy works" page on port 80.

The Caddyfile

Edit /etc/caddy/Caddyfile. The smallest useful site:

example.com {
    root * /var/www/example
    file_server
}

Three lines and you get:

  • Automatic certificate issuance from Let's Encrypt (with ZeroSSL as fallback) on first request
  • HTTP→HTTPS redirect
  • Modern TLS defaults (TLS 1.2+, sane cipher list, OCSP stapling)
  • HTTP/2 and HTTP/3
  • Auto-renewal — checked daily by the running daemon, no cron needed

For the cert issuance to succeed, port 80 and port 443 must both be open and reachable, and DNS must point at this host. That's it — the HTTP-01 challenge runs through Caddy's own listener.

Reload the config (no restart needed):

sudo systemctl reload caddy

Reverse proxy

The most common shape — terminate TLS, forward to a backend on localhost:

api.example.com {
    reverse_proxy localhost:8080
}

# Load-balance across multiple backends
api2.example.com {
    reverse_proxy 10.0.0.10:8080 10.0.0.11:8080 10.0.0.12:8080 {
        lb_policy round_robin
        health_uri /healthz
        health_interval 10s
    }
}

Caddy forwards the original Host header and adds X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host by default — no extra config needed for most apps.

Static site + SPA fallback

app.example.com {
    root * /var/www/app
    encode zstd gzip
    try_files {path} /index.html
    file_server
}

try_files serves the requested file if it exists, otherwise falls back to index.html — the right behavior for a client-side-routed React/Vue/Svelte build.

PHP-FPM

For a classic LAMP stack, php_fastcgi wraps the common Caddyfile boilerplate (index file, hidden .php handler, path-info splitting):

wordpress.example.com {
    root * /var/www/wordpress
    php_fastcgi unix//run/php/php8.3-fpm.sock
    file_server
}

Multi-site

Either add more site blocks to the same Caddyfile, or use the per-site import pattern:

# /etc/caddy/Caddyfile
import /etc/caddy/sites/*.caddy
# /etc/caddy/sites/blog.caddy
blog.example.com {
    root * /var/www/blog
    file_server
}

One file per site, easy to grep and to comment out individually.

Snippets and named matchers

For repeated headers or behavior, use a snippet (referenced with the import directive):

(security_headers) {
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains; preload"
        X-Content-Type-Options nosniff
        Referrer-Policy strict-origin-when-cross-origin
        Permissions-Policy "geolocation=(), microphone=(), camera=()"
        -Server
    }
}

site1.example.com {
    import security_headers
    reverse_proxy localhost:3000
}

site2.example.com {
    import security_headers
    reverse_proxy localhost:3001
}

Validate before reloading

sudo caddy validate --config /etc/caddy/Caddyfile
sudo caddy fmt --overwrite /etc/caddy/Caddyfile

caddy fmt reformats in place — consistent indentation, normalized directive ordering. A natural fit for a pre-commit hook.

Where the certificates live

Caddy writes them to its data directory:

/var/lib/caddy/.local/share/caddy/certificates/acme-v02.api.letsencrypt.org-directory/example.com/

You shouldn't need to touch them — Caddy serves and renews them itself — but they're standard PEM if you need to extract one. Permissions are 700 for the caddy user.

Want to keep the ACME account portable?

Set email you@example.com in the global options block at the very top of the Caddyfile. Caddy will register an ACME account under that email and store the account key under the same data dir — back it up if you care about preserving rate-limit history when moving hosts.

HTTP/3 (QUIC)

Already on, listening on UDP/443. Verify with:

curl --http3 -I https://example.com

(Needs curl compiled with HTTP/3 support — recent Debian's curl has it.) Or check the alt-svc header in any HTTPS response from your server. Make sure UDP/443 is open in your firewall and any cloud security group.

Logs

By default Caddy logs to systemd's journal. Per-site structured access logs:

example.com {
    log {
        output file /var/log/caddy/example.com.log {
            roll_size 100MiB
            roll_keep 5
        }
        format json
    }
    reverse_proxy localhost:8080
}

caddy handles rotation itself — you don't need logrotate.

Troubleshooting

  • Certificate fails on first request. Check that DNS for the hostname actually points at this server (dig +short example.com) and that ports 80 and 443 are open. journalctl -u caddy -n 100 shows the ACME error, which is usually a 404 from somewhere unexpected or a CAA record blocking Let's Encrypt.
  • "Too many failed authorizations" from Let's Encrypt. You hit the rate limit while debugging. Either wait an hour, or temporarily switch to the staging CA: add acme_ca https://acme-staging-v02.api.letsencrypt.org/directory in the global options block, fix things, then remove it. Staging certs aren't trusted, so they're for testing the issuance flow only.
  • Reload doesn't pick up changes. Caddy reload is graceful but does validate first; check caddy validate. A bad Caddyfile keeps the old config running.
  • Caddy fights another process for port 80/443. Old nginx or Apache still bound. sudo systemctl disable --now nginx apache2 first.