I have been telling people to use Caddy for about four years, and my entire argument fit in one sentence: it does HTTPS for you and nginx doesn’t.
That sentence quietly stopped being true and I didn’t notice for months. nginx shipped a native ACME module. Not a wrapper around certbot, not a shell script someone maintains on alternate Wednesdays. A real Rust dynamic module that requests, installs and renews certificates from inside the nginx config file. I found out from a Let’s Encrypt writeup, felt mildly betrayed by my own opinion, and spent a Saturday rebuilding a staging box both ways to see whether I still believed the thing I had been saying at every meetup for four years.
Short version: I still reach for Caddy on small boxes. The reason is different now, and it is a much narrower reason than it used to be. If you picked Caddy purely to escape certbot, you picked it for a reason that has expired.
What nginx actually shipped
The module is ngx_http_acme_module. It is written in Rust on top of the ngx-rust SDK, it ships as a dynamic module for both open source nginx and nginx Plus, and it is packaged as nginx-module-acme for anyone on the official repos. The announcement is on the NGINX community blog and the reference is in the module documentation. Let’s Encrypt also wrote about it, which is how it landed in my feed.
Here is the whole thing, working, on a box I actually run:
resolver 127.0.0.1:53;
acme_issuer letsencrypt {
uri https://acme-v02.api.letsencrypt.org/directory;
contact [email protected];
state_path /var/cache/nginx/acme-letsencrypt;
accept_terms_of_service;
}
acme_shared_zone zone=acme_shared:1M;
server {
listen 443 ssl;
server_name app.example.com;
acme_certificate letsencrypt;
ssl_certificate $acme_certificate;
ssl_certificate_key $acme_certificate_key;
ssl_certificate_cache max=2;
}
server {
# required so the HTTP-01 challenge can be answered
listen 80;
location / { return 404; }
}
Two things in there bit me. The resolver line is not optional, because the module talks to the ACME directory over the network and nginx needs a DNS resolver it can use at runtime. And state_path is the difference between account keys surviving a restart and you burning through Let’s Encrypt rate limits while you debug. The default shared zone holds roughly 50 ECDSA keys, so on a box with three vhosts you can leave it alone. I bumped it to 1M out of habit and mild paranoia.
The limits are real. The preview only does HTTP-01 and TLS-ALPN-01 challenges. No DNS-01, which means no wildcard certificates. Wildcards and regex in server_name are not valid identifiers either, so *.example.com is out. If your setup depends on a wildcard cert, nothing here helps you yet and you are staying on certbot or lego.
The certbot setup I was replacing
This is the part people forget when they compare the two. The old nginx way was never “nginx plus one command”. It was nginx, plus certbot, plus a Python runtime, plus a renewal timer, plus a deploy hook that reloads nginx, plus remembering that the deploy hook exists when something breaks eighteen months later.
# the old shape
apt install certbot python3-certbot-nginx
certbot --nginx -d app.example.com
# then trust that this thing keeps firing forever
systemctl list-timers certbot.timer
I wrote a whole post about why I stopped trusting cron jobs for exactly this class of work, and certificate renewal was the thing that pushed me over the edge. A renewal timer that silently fails is not a loud failure. It is a quiet one that turns into a very loud failure roughly 89 days later, usually on a Sunday.
The native module collapses that whole apparatus into config. No extra runtime. No timer to audit. The renewal lives inside the process that actually serves the certificate, which is where it always should have lived.
There is a second thing certbot did that I only appreciated once it was gone. It edited my nginx config for me. The --nginx plugin would find the server block, add the ssl_certificate lines, add the redirect, and hand it back. That is convenient right up until you want to know what your config actually says, at which point you are reading someone else’s generated comments and trying to work out which lines are yours and which arrived by machine. With the module, every line in that file is a line I typed. It sounds like a small thing. It is the difference between debugging a config and doing archaeology on one.
What Caddy still does that nginx doesn’t
I want to be fair here, because I came into the weekend hoping to prove myself right and mostly failed.
Caddy’s automatic HTTPS still does more. DNS-01 challenges via provider modules, so wildcards work. On-demand TLS, which issues a certificate the first time an unknown hostname shows up, which is the feature you need if you host customer domains and cannot enumerate them ahead of time. Automatic OCSP stapling. Automatic HTTP to HTTPS redirects that you did not write. Certificate storage that can be shared across a cluster.
And the config is still shorter by an embarrassing margin:
app.example.com {
reverse_proxy localhost:3000
}
That is the entire file. TLS, redirect, HTTP/2, HTTP/3, all of it, from three lines. The nginx equivalent with the ACME module runs to about twenty five lines once you add the proxy headers, and you will get one of them wrong the first time.
Caddy also keeps moving. The 2.11 releases added automatic ECH key rotation, a tls_resolvers global option for DNS challenges, and a change where reverse_proxy rewrites the Host header automatically when the upstream is HTTPS. That last one deleted a line of config I had been copy pasting since 2021 without fully understanding it.
The performance question, which is mostly a distraction
Every thread comparing these two turns into benchmarks within about nine replies, so let me get it out of the way. On the staging box I used, a 4 vCPU machine serving a Node app behind the proxy, both were comfortably faster than the application they were fronting. Caddy sat higher on resident memory, which is what you would expect from a Go binary with a garbage collector versus a C process with a hand tuned allocator. We are talking tens of megabytes. On a machine with 8GB of RAM that is not a number I plan to organise my week around.
Where you can feel a difference is under configurations with hundreds of upstreams and aggressive connection reuse, and even then the honest answer is that your database is the bottleneck and you already know it. I have never once had a proxy be the slow part of a small team’s stack. I have had the proxy be the confusing part many times, which is an argument about config files, not throughput.
If you are running something where proxy overhead genuinely matters, you are past the point where a blog post helps and you should be measuring your own traffic shape on your own hardware. For everyone else: pick the one whose config you can read at midnight.
So what actually changed for me
My old rule was “small box, use Caddy, because certbot is a tax”. That rule was doing two jobs at once and I only just noticed. One job was “certificate automation should not be a separate moving part”. The other was “I don’t want to write forty lines of nginx to serve one app”.
nginx solved the first one. It did not solve the second one, and it is not trying to.
So the rule I use now is narrower and I think more honest.
If the box already runs nginx, or your team already knows nginx, or you need something from nginx’s ecosystem that Caddy doesn’t have (njs, a specific third party module, a load balancing config someone tuned in 2019), install the ACME module and delete certbot. You get the automation without the migration. That is a Tuesday afternoon of work, not a project.
If you are standing up something new, it is a handful of services behind a proxy, and nobody on the team has strong nginx feelings, Caddy is still the faster path. Not because of certificates any more. Because of the config file.
If you need wildcards or on-demand TLS, the decision makes itself. Caddy, or nginx with an external ACME client. The native module cannot do it yet.
I run a mix of both across the boxes I look after for clients, which is less a philosophy than an accumulation of history, and I have made my peace with that. I write up more of these infrastructure calls in the work I do on small team platforms, mostly because I keep having the same conversation and would rather link to it than repeat it.
The thing I got wrong
For about two years I told people the nginx maintainers didn’t care about certificate automation. That was lazy. What actually happened is that nginx let certbot own the problem for a decade, certbot did a genuinely good job of it, and then the calculus changed once ACME clients stopped being exotic. The module being written in Rust and shipped as a dynamic module rather than baked into core also tells you something about how F5 wants to iterate on it, which I read as a good sign rather than a hedge.
I also assumed “native” would mean “better”. It mostly means “fewer parts”. Certbot has more features today. It has DNS plugins for every provider you have heard of and several you haven’t. If you already have a certbot setup that works and you understand it, the honest answer is that ripping it out buys you a smaller attack surface and one fewer thing in your dependency tree, and not a great deal else. That may be enough for you. It may not be.
What to do this week
Pick one staging box. Not production, staging, because the first time you get resolver wrong nginx will start happily and then fail to issue, and the error lives in the error log rather than anywhere obvious.
Install nginx-module-acme from the official repo, add the acme_issuer block pointing at the Let’s Encrypt staging directory (https://acme-staging-v02.api.letsencrypt.org/directory), and watch the error log while you reload. Staging has generous rate limits, which matters because you will reload more times than you expect. Once it issues cleanly against staging, swap the URI to production, wipe the state_path directory, reload once more.
Then, and this is the part I nearly skipped, actually uninstall certbot and disable its timer. Leaving both running is how you end up with two things fighting over the same certificate file at 3am. Ask me how I know.