Caddy Server: a modern web server
Caddy automatically obtains and renews SSL certificates via Let's Encrypt without any manual configuration. This is the main advantage over Nginx — no Certbot needed.
Installation on Ubuntu/Debian
apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg
curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | tee /etc/apt/sources.list.d/caddy-stable.list
apt update && apt install caddyBasic configuration (Caddyfile)
# /etc/caddy/Caddyfile
yourdomain.com {
root * /var/www/html
file_server
encode gzip
}
api.yourdomain.com {
reverse_proxy localhost:3000
}PHP-FPM with Caddy
yourdomain.com {
root * /var/www/html
encode gzip
php_fastcgi unix//run/php/php8.3-fpm.sock
file_server
try_files {path} {path}/ /index.php?{query}
}Security headers
yourdomain.com {
root * /var/www/html
file_server
header {
Strict-Transport-Security "max-age=31536000; includeSubDomains"
X-Content-Type-Options nosniff
X-Frame-Options DENY
-Server
}
}Load balancing
yourdomain.com {
reverse_proxy {
to localhost:8001 localhost:8002 localhost:8003
lb_policy round_robin
health_uri /ping
health_interval 15s
}
}Service management
systemctl reload caddy
caddy validate --config /etc/caddy/Caddyfile
caddy fmt --overwrite /etc/caddy/CaddyfileReal Caddy Errors Explained
Caddy strives to be predictable, but two classes of problems show up regularly: insufficient privileges on privileged ports, and a failed automatic certificate issuance. Let's look at the exact log messages.
Permission Denied on Startup
{"level":"fatal","msg":"listen tcp :443: bind: permission denied"}Ports below 1024 require root privileges or the CAP_NET_BIND_SERVICE capability. Caddy's official systemd unit already includes that capability via AmbientCapabilities, so this usually shows up when running the binary manually (caddy run as a regular user) or in a container without --cap-add=NET_BIND_SERVICE. The fix: start it through systemctl start caddy, or add the capability explicitly with setcap 'cap_net_bind_service=+ep' /usr/bin/caddy.
The ACME Challenge Fails
{"level":"error","msg":"job failed","error":"leaf certificate creation failed for yourdomain.com: ... [400] urn:ietf:params:acme:error:connection: Fetching http://yourdomain.com/.well-known/acme-challenge/xxxx: Connection refused"}Let's Encrypt could not reach your server on port 80 for the HTTP-01 challenge. The causes are almost always the same: port 80 is closed in the firewall, the domain's DNS record has not propagated yet, or port 80 is already taken by another process (check with ss -tlnp | grep :80). Until the challenge succeeds, Caddy serves a self-signed certificate instead of a real one.
Caddy vs. Nginx vs. Traefik
All three servers solve the reverse proxy problem, but they prioritize different things.
| Criterion | Caddy | Nginx | Traefik |
|---|---|---|---|
| Auto-HTTPS out of the box | Yes | No, needs Certbot | Yes |
| Config format | Caddyfile, declarative | nginx.conf, imperative | YAML or Docker labels |
| Native PHP-FPM support | Yes, php_fastcgi | Yes, fastcgi_pass | Not directly |
| Best fit for | Small and mid-sized projects with auto-SSL | High-load static content and proxying | Docker and Kubernetes environments |
When Caddy Is Not a Good Fit
- You need the ecosystem of mature third-party modules — ModSecurity, njs scripts, decades of ready-made configuration recipes that Nginx has.
- The team already maintains hundreds of lines of battle-tested nginx.conf and migration would cost more than the benefit of auto-HTTPS.
- You need raw L4 TCP/UDP proxying without building a custom binary via xcaddy with the layer4 plugin.
- The infrastructure is entirely Docker/Kubernetes with label-based service discovery — Traefik fits more naturally there.
- You need fine-grained on-disk caching (Nginx-style proxy_cache) — in Caddy that requires a third-party cache-handler module.
Verifying the Result
# Check syntax before applying
caddy validate --config /etc/caddy/Caddyfile
# Make sure the service is up and listening on the ports
systemctl status caddy
ss -tlnp | grep -E ':(80|443)'
# Confirm the certificate is real, not self-signed
curl -vI https://yourdomain.com 2>&1 | grep -i "subject\|issuer"
# View the latest log lines when troubleshooting
journalctl -u caddy -n 50 --no-pagerIf issuer in the curl output shows CN=Caddy Local Authority instead of Let's Encrypt, the ACME challenge did not pass and the site is running on a self-signed certificate. Check the logs with journalctl and verify DNS together with port 80 separately, before restarting Caddy. If Caddy sits in front of several backends, cross-check the load distribution against the material on Docker Swarm, and it is convenient to move backend liveness monitoring into Uptime Kuma.