I had a certbot renew cron silently fail for three weeks because I'd added a server block to nginx that listened on :80 without a default location, and certbot's webroot challenge couldn't write to /var/www/html/.well-known/. I only noticed when my monitoring pinged me about an expired cert on a Saturday morning. I fixed it in ten minutes, then spent the rest of the weekend ripping that box's TLS story out and replacing it with Caddy. Three sites moved over. Zero cron jobs. I haven't touched cert renewal since.
That doesn't mean I'm a Caddy zealot. I still run nginx in front of the blog you're reading this on, because it gets enough traffic that I want the tuning knobs. But for the dozen little internal tools, dashboards, and side-project APIs I run on a Proxmox VM on node1, Caddy is the right answer and it's not close.
Writing this down because I keep getting asked which one to pick.
TL;DR
This is a complete, working Caddyfile for two sites with HTTPS, HTTP/2, OCSP stapling, and auto-renewing Let's Encrypt certs:
example.com {
reverse_proxy localhost:8080
}
api.example.com {
reverse_proxy localhost:3000
}
Five lines (six if you count the blank one). sudo systemctl reload caddy and you're done. Caddy provisions certs, keeps them renewed, redirects HTTP to HTTPS, and sets sensible TLS defaults. There's no certbot, no cron, no --webroot flag, no ssl_protocols TLSv1.2 TLSv1.3 line you copy-pasted from a 2019 blog post.
The same thing in nginx + certbot
Here's the equivalent in nginx-land. First the bare config you write:
server {
listen 80;
server_name example.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Then:
sudo certbot --nginx -d example.com
Certbot mangles the file. After it runs you have something like this — and you'd better hope you don't need to edit it by hand later, because the comments matter:
server {
server_name example.com;
location / {
proxy_pass http://localhost:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
listen 443 ssl; # managed by Certbot
ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; # managed by Certbot
ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; # managed by Certbot
include /etc/letsencrypt/options-ssl-nginx.conf; # managed by Certbot
ssl_dhparam /etc/letsencrypt/ssl-dhparam.pem; # managed by Certbot
}
server {
if ($host = example.com) {
return 301 https://$host$request_uri;
} # managed by Certbot
listen 80;
server_name example.com;
return 404; # managed by Certbot
}
Plus a systemd timer (or cron entry, depending on how certbot was installed) running certbot renew twice a day. Plus the renewal hook that reloads nginx. Plus the snippets file at /etc/letsencrypt/options-ssl-nginx.conf that you should probably read once and then never think about.
Thirty-something lines vs five. And every single one of those nginx lines is a place I've personally broken something at 2 AM.
When Caddy wins
This is most of my life now:
- Reverse-proxying a single backend. A Go binary, a Node app, whatever. Three lines of Caddyfile, done.
- Tiny boxes. A 1 GB RAM VPS doesn't want to run nginx + certbot's Python stack + a renewal hook. Caddy is one Go binary.
- Anything you'd Ansible. Caddyfiles are stupidly easy to template. No certbot DNS dance, no renewal-hook coordination.
- No cron. This is the big one for me. Cron-based renewal is fine when it works, and a silent disaster when it doesn't. Caddy renews from inside the running process and reloads itself.
- Internal stuff behind a Tailscale or WireGuard network where I just want HTTPS without thinking about it. Caddy handles HTTP-01 over the public DNS name even when the box is on a private IP, as long as DNS resolves. Or use the DNS challenge plugin and never expose port 80 at all.
If your config is "domain points at backend, please give me HTTPS", Caddy is unambiguously the better tool.
When nginx still wins
I'm not throwing my nginx configs away:
- High RPS. Caddy is fine, but nginx's worker model is better understood and there's a decade of tuning advice for it. If you're pushing serious traffic, nginx + a good
worker_connectionsandkeepalivesetup is still the move. - Custom modules. Lua, Brotli compiled in, ModSecurity, RTMP, the weird stuff. nginx's module ecosystem is enormous; Caddy's is smaller and you often need a custom build.
- Complex routing and regex. Once you're doing rewrite-with-capture-groups, conditional
try_files,mapblocks, and a dozenifstatements (yes, I know — "if is evil"), nginx's syntax wins. Caddy can do most of it, but it's verbose. - You already know nginx. Don't underestimate this. If you've got 200 lines of working nginx and a runbook, switching for the sake of switching is a bad trade.
I have a long-standing setup using the patterns from my nginx reverse proxy notes for the blog. It serves static files, proxies to a backend, has a custom 404 page, and gzips selectively. I wouldn't move it.
Switching one site without downtime
The trick is the HTTP-01 challenge. Both nginx and Caddy want port 80 for it. You can't run both bound to :80 at the same time on the same interface, but you only need a few seconds of handoff.
Here's what I do:
Install Caddy. Don't start it yet, or start it on a different port for testing (
:8080/:8443).In your nginx config for the site you're moving, add a location block that proxies the ACME challenge path to Caddy:
location /.well-known/acme-challenge/ { proxy_pass http://127.0.0.1:8080; }Reload nginx.
Configure Caddy on
:8080(no TLS) for that domain, withreverse_proxyto your real backend. Let Caddy fetch the cert via the proxied challenge path. It'll succeed because nginx is forwarding the challenge.Once Caddy has the cert (check
/var/lib/caddy/.local/share/caddy/certificates/), remove the site from nginx, reload nginx, and switch Caddy to listen on:80and:443.Reload Caddy. Total downtime per site: under a second, if any.
Don't do this for fifty sites in one afternoon — Let's Encrypt's rate limits will catch you (more on that below).
Gotchas
Four things I've actually been bitten by, in roughly the order I hit them.
Caddy stores certs in /var/lib/caddy and breaks if you switch the running user. The default systemd unit runs Caddy as the caddy user with XDG_DATA_HOME=/var/lib/caddy. If you ever run sudo caddy run from a shell to "just test something," it'll write certs to /root/.local/share/caddy/, get them signed, and then your systemd-launched Caddy won't see them and will go re-fetch — possibly hitting rate limits. Always use sudo systemctl reload caddy. Always.
caddy run vs the systemd unit are not interchangeable. caddy run runs in the foreground and uses the Caddyfile in your current directory unless you --config it. The systemd unit reads /etc/caddy/Caddyfile. If you're editing ~/Caddyfile and running caddy run to test, you're testing the wrong file. I've wasted an embarrassing amount of time on this. Edit /etc/caddy/Caddyfile, run sudo caddy validate --config /etc/caddy/Caddyfile, then sudo systemctl reload caddy.
reverse_proxy doesn't forward every header you might expect. It sets X-Forwarded-For, X-Forwarded-Proto, and X-Forwarded-Host automatically, which is great. But if your backend reads Host and you want it to see the original public hostname, that's already the default. If you want to override it (e.g. send Host: localhost to the backend), you need:
reverse_proxy localhost:8080 {
header_up Host {upstream_hostport}
}
The default in Caddy v2 is to pass through the original Host. nginx defaults to whatever's in proxy_pass. This trips up people coming from nginx all the time.
Let's Encrypt rate limits during migration. If you're moving twenty sites in one go and you mess up the staging dance, you can hit the 50-certs-per-registered-domain-per-week limit fast. Use the staging endpoint while you're testing:
{
acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}
at the top of your Caddyfile. Switch to production only when you're confident the config is right. Staging certs aren't trusted by browsers, but they prove the flow works.
What I run
Honestly: a mix.
- The blog and one high-traffic API: nginx, with certbot on a systemd timer. Battle-tested, tuned, monitored. Not changing.
- Everything else: Caddy. Internal Grafana, a Gitea instance, a couple of dashboards, a webhook receiver, three personal projects, the WireGuard admin UI, a private package mirror. All on Caddy. All five-line site blocks. All on auto-pilot since I switched.
If you're spinning up a new box this weekend and you don't have strong nginx muscle memory, start with Caddy. If you've got a working nginx config and certbot is renewing fine, leave it alone. The right answer is usually "both, in different places" — pick the one whose failure modes you can live with for that specific service.
I'll keep nginx around as long as I'm running anything that benefits from its tuning. But I haven't written a fresh server { listen 443 ssl; ... } block in over a year, and I don't miss it.