Nginx is the web server most of the internet runs on, and it will do anything you ask it to. Caddy does a narrower set of things and does one of them — getting and renewing TLS certificates — without you configuring anything at all.
The short verdict. If you are putting a handful of services behind HTTPS on your own server, use Caddy. The config is a fifth the size, certificates are obtained and renewed automatically with nothing to schedule, and there is no certbot to break at renewal time eighteen months from now.
Keep nginx when you already have working nginx config you understand, when you need something only nginx does (fine-grained caching, complex rewrite chains, mail proxying, third-party modules), when your team’s existing knowledge is nginx, or when you are following documentation — nginx is what nearly every tutorial assumes.
Side by side
| nginx | Caddy | |
|---|---|---|
| TLS certificates | certbot, plus a renewal timer you set up | Automatic, built in, nothing to configure |
| Config size for one HTTPS site | ~20 lines plus a certbot run | 2 lines |
| Config format | Its own syntax | Caddyfile, with JSON underneath |
| Reload without dropping connections | Yes | Yes |
| HTTP/3 | Supported, needs enabling | On by default |
| Adding functionality | Modules, often needing a rebuild | Plugins, via a rebuild with xcaddy |
| Written in | C | Go — one static binary |
| Raw throughput | Higher at extreme volumes | Ample below that |
| Documentation you will find online | Enormous | Good official docs, far fewer blog posts |
The throughput row is worth being honest about. Nginx wins benchmarks, and for the overwhelming majority of servers that difference is irrelevant — the bottleneck is the application behind the proxy, not the proxy. If you are at the scale where it matters, you already know.
Installing
# Debian and Ubuntu, from Caddy's own repository
sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl
# then add the repo per caddyserver.com/docs/install, and:
sudo apt install caddy
# Fedora, RHEL and friends
sudo dnf install caddy
# Arch
sudo pacman -S caddyThe package installs a systemd unit and a config at /etc/caddy/Caddyfile. Because Caddy is a single static Go binary, dropping the release binary somewhere and writing your own unit also works fine — useful on a distribution with no package.
The naming trap: plugins are not installed at runtime in the normal sense. Caddy is compiled with the plugins it has, so adding one means building a new binary with xcaddy, or using the official download page to generate a build with your chosen plugins included. There are caddy add-package and caddy upgrade commands that automate this by fetching a fresh build, but they are still marked experimental — for a production box, build deliberately rather than upgrading in place.
The same site, both ways
A reverse proxy for one application, with HTTPS. In nginx, after running certbot:
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
server_name app.example.com;
ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
location / {
proxy_pass http://127.0.0.1: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;
}
}In Caddy, the entire equivalent:
app.example.com {
reverse_proxy 127.0.0.1:8080
}That is not a simplified example. The certificate is obtained on first request and renewed thereafter, HTTP is redirected to HTTPS, and the forwarded headers are set — all because those are the defaults rather than things you asked for.
What automatic HTTPS actually does
Caddy serves everything over HTTPS by default, and it handles two cases differently.
For a public DNS name, it requests a certificate from a public ACME certificate authority, keeps it renewed, and redirects port 80 to port 443. You configure nothing; the requirement is simply that the name resolves to the machine and that ports 80 and 443 are reachable.
For a local or internal name — localhost, anything under .localhost, .local, .internal or .home.arpa, and bare IP addresses — it cannot use a public CA, so it generates its own local certificate authority and signs a certificate with that. On first use it tries to install its root into the system trust store, which is why running Caddy locally sometimes prompts for a password. The explicit form is tls internal.
Automatic HTTPS switches itself off when there are no hostnames to work with, when you disable it, or when the server is listening only on the HTTP port. That last one is the usual accidental cause — a site block written as http://app.example.com gets no certificate, by design.
What that removes is worth naming precisely, because it is the step that actually breaks elsewhere. With certbot, the renewal and the web server are separate programs: certbot writes new files and something else has to reload nginx to pick them up. When that something else is missing, the renewal keeps succeeding, the logs stay clean, and the expired certificate keeps being served. Caddy renews inside its own process and swaps the certificate in memory, so there is no reload to forget. The verification is the same either way — compare the serial on the wire with the serial on disk — and The Life of a Certificate walks the six stages behind all of it, including the ones Caddy is doing silently.
Translating what you already know
| nginx | Caddy |
|---|---|
server { server_name x; } | x { } |
proxy_pass http://... | reverse_proxy ... |
root /var/www; index index.html; | root * /var/www then file_server |
location /api { } | handle /api/* { } |
return 301 https://... | Automatic — or redir |
gzip on; | encode gzip zstd |
auth_basic | basic_auth |
nginx -t | caddy validate |
nginx -s reload | caddy reload or systemctl reload caddy |
The conceptual difference to watch for: nginx picks one location block by a precedence algorithm that catches people out for years. Caddy’s handle blocks are mutually exclusive and matched in order, which is duller and much easier to predict.
If you have a large existing nginx config, Caddy’s nginx config adapter can convert it — treat the output as a first draft to read rather than something to deploy.
Where nginx is still the right answer
- Serious content caching.
proxy_cacheand its surrounding controls have no equally mature equivalent. - Complicated rewrite and rate-limiting logic that already exists and already works. Rewriting it to prove a point is not progress.
- Anything mail-related. Nginx can proxy IMAP, POP3 and SMTP; Caddy does not try.
- You are following someone else’s runbook. Most self-hosting documentation assumes nginx, and translating while also debugging doubles the difficulty.
- Certificates come from somewhere else entirely — a corporate CA, a load balancer, an existing PKI. Caddy handles this fine, but its main advantage disappears.
Worth knowing for the record: Caddy is Apache-2.0 licensed open source, and is now a project of ZeroSSL, which is part of HID Global. That has not changed the licence or the free binaries, but it is the kind of thing to be aware of before standardising a company on it.
Configuration worth having
# /etc/caddy/Caddyfile
{
email you@example.com # for expiry notices from the CA
}
app.example.com {
reverse_proxy 127.0.0.1:8080
encode gzip zstd
log {
output file /var/log/caddy/app.log
}
}
files.example.com {
root * /srv/files
file_server browse
basic_auth {
alice $2a$14$... # from: caddy hash-password
}
}
# several apps on one name
example.com {
handle /api/* {
reverse_proxy 127.0.0.1:3000
}
handle {
root * /srv/site
file_server
}
}caddy fmt --overwrite /etc/caddy/Caddyfile # normalise formatting
caddy validate --config /etc/caddy/Caddyfile # check before reloading
sudo systemctl reload caddy # no dropped connectionsSet that email — but do not treat it as a safety net. Let’s Encrypt switched off expiry notification emails on 4 June 2025 and deleted the stored addresses from its production database, so the warning that older guides promise no longer arrives. The address is still worth setting, because it is your account’s contact record and other certificate authorities still use it; the actual monitoring has to be yours.
Common problems
| Symptom | Cause | Fix |
|---|---|---|
| No certificate issued | Port 80 blocked, or DNS not pointing here yet | Open 80 and 443; check the name resolves |
| Browser warns about a self-signed certificate | The name is local, or it is an IP address | Expected — use a real name, or trust the local root |
| Site serves over HTTP only | Site block written as http:// | Drop the scheme |
| Certificate requests being rate-limited | Repeated failed attempts against the live CA | Use the staging CA while testing |
| Application sees every request as coming from 127.0.0.1 | Not reading the forwarded headers | Configure the app to trust the proxy |
| A plugin is missing | Not compiled into this binary | Rebuild with xcaddy |
| Config edit had no effect | Never reloaded | caddy validate, then reload |
Quick reference
caddy run --config Caddyfile # foreground, for debugging
caddy validate --config Caddyfile # syntax and module check
caddy fmt --overwrite Caddyfile # tidy it
caddy reload --config Caddyfile # apply with no downtime
caddy hash-password # for basic_auth
caddy file-server --listen :8080 # serve this directory, right now
xcaddy build --with github.com/caddy-dns/cloudflareRelated reading
- A reverse proxy with automatic TLS — the full walkthrough
- The Life of a Certificate — the six stages Caddy is doing on your behalf, and the one it removes
- Nginx — the classic, in depth
- What a TLS certificate actually proves — what is being automated
- openssl — checking what a server is really serving
- Self-hosting your first application — where this sits in the stack
