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.
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 100shows 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/directoryin 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 apache2first.