Nginx vs Caddy: Automatic HTTPS vs Full Control
Caddy gives you automatic HTTPS and a 4-line config. Nginx gives you raw throughput and a 25-year ecosystem. Here's when each one is the right call.
Nginx vs Caddy: Automatic HTTPS vs Full Control
You're spinning up a new service — a Node.js API, a side project, an internal tool. You need a reverse proxy in front of it. You know you need HTTPS. And suddenly you're staring at a 30-line nginx.conf just to forward traffic to port 3000 and handle a Let's Encrypt cert — before you've touched timeouts, buffers, or rate limiting.
Then someone tells you about Caddy.
With Caddy, that same setup is three lines. TLS handled automatically. HTTP/3 on by default. No Certbot, no cron job, no certificate renewal anxiety.
So is Nginx just legacy at this point? Not quite. The gap is real, but so are the reasons teams with serious traffic still reach for Nginx first. Let's break down where each one actually shines.
Quick Decision Matrix
| If you need... | Use |
|---|---|
| Zero-config HTTPS with auto-renewal | Caddy |
| Maximum raw throughput at extreme scale | Nginx |
| Minimal config for a side project or internal tool | Caddy |
| Nginx Plus features (enterprise load balancing, active health checks) | Nginx |
| HTTP/3 (QUIC) out of the box | Caddy |
| A battle-tested server your ops team already knows | Nginx |
| Dynamic config via a JSON API | Caddy |
| Fine-grained module control (OpenResty, Lua, etc.) | Nginx |
| Docker-based routing with simple labels | Either (Caddy wins on simplicity) |
| Multi-tenant certificate management for 100+ domains | Caddy |
Configuration Syntax
This is where you feel the difference immediately.
Here's a complete Nginx reverse proxy config for a Node.js app with HTTPS:
# /etc/nginx/sites-available/myapp
server {
listen 80;
server_name api.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on; # the `http2` listen parameter is deprecated since 1.25.1
server_name api.example.com;
ssl_certificate /etc/letsencrypt/live/api.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/api.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256;
ssl_prefer_server_ciphers off;
add_header X-Frame-Options "SAMEORIGIN";
add_header X-Content-Type-Options "nosniff";
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
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;
proxy_cache_bypass $http_upgrade;
}
}That's about 30 lines before you've even thought about timeouts, buffer sizes, or rate limiting. And this doesn't include the Certbot setup.
Here's the equivalent Caddyfile:
api.example.com {
reverse_proxy localhost:3000
}That's it. Caddy reads the domain name, sees it's a real hostname, reaches out to Let's Encrypt, gets a cert, handles the HTTP→HTTPS redirect, and proxies requests — all in three lines.
Server: Caddy and an Alt-Svc header advertising HTTP/3, and that's it — no Strict-Transport-Security, no X-Frame-Options, no X-Content-Type-Options, no CSP. Plenty of write-ups claim Caddy ships "sane security defaults"; it ships sane TLS defaults. If you want headers you write a header block, exactly as you would in nginx. An automatic-HSTS proposal was opened and closed in 2022 without being implemented.For multiple services with path-based routing:
# Caddy — all three blocks are `handle`, so they're mutually exclusive
# and Caddy sorts them most-specific-first.
app.example.com {
handle /api/* {
reverse_proxy localhost:3000
}
handle /admin/* {
reverse_proxy localhost:4000
}
handle {
root * /var/www/html
file_server
}
}route and a bare handle in one site block. route /api/* { … } alongside a matcher-less handle { … } passes caddy validate — exit code 0, "Valid configuration" — but Caddy's directive sorter places the unmatched handle first, so it swallows every request and the route blocks become dead code. I confirmed this against Caddy 2.11.4: GET /api/foo was served by the catch-all, not the proxy. A config that validates cleanly and silently routes everything to the wrong place is the worst failure mode there is. Use handle throughout, or give the catch-all an explicit matcher.# Nginx equivalent (abbreviated — you still need the ssl_* block above)
location /api/ {
proxy_pass http://127.0.0.1:3000/;
# ... 6 more proxy_set_header lines
}
location /admin/ {
proxy_pass http://127.0.0.1:4000/;
# ... 6 more proxy_set_header lines
}
location / {
root /var/www/html;
try_files $uri $uri/ =404;
}The Nginx version is repetitive in a way that's easy to get wrong. Forget proxy_set_header X-Forwarded-Proto $scheme in one location block and you'll spend 45 minutes wondering why your app thinks it's on HTTP.
Verdict: Caddy wins decisively for config ergonomics. Nginx config is more powerful, but you're doing a lot of boilerplate work by hand that Caddy handles as defaults.
TLS Certificate Management
This is Caddy's killer feature and worth understanding properly.
Nginx does not obtain TLS certificates. It serves them. You're responsible for getting them from somewhere — typically Certbot — and telling Nginx where they are. You also need to ensure renewal happens (usually via a cron job or systemd timer). If the renewal fails and you don't notice, your cert expires and your site goes down.
Caddy handles the full certificate lifecycle automatically:
- On startup, it identifies all domains in your config
- It performs ACME challenges (HTTP-01 by default, DNS-01 if configured) with Let's Encrypt or ZeroSSL
- Certs are stored locally and renewed before they expire
- If you add a new domain to the Caddyfile and reload, it gets a cert immediately
# Caddy: three domains, three certs, zero extra config
api.example.com {
reverse_proxy localhost:3000
}
app.example.com {
reverse_proxy localhost:4000
}
internal.company.com {
tls internal # cert from Caddy's built-in local CA (not self-signed)
reverse_proxy localhost:5000
}Worth being precise about tls internal: it doesn't produce a self-signed certificate. It issues a leaf signed by Caddy's own local CA, which has a proper root → intermediate → leaf chain (the issuer reads CN = Caddy Local Authority - ECC Intermediate). Caddy tries to install that root into the system trust store, which typically fails when running non-root or in a container — then you need caddy trust. The leaf lifetime is 12 hours, so this only works with Caddy running to keep renewing; don't try to export the cert and use it elsewhere.
For wildcard certs, you need DNS-01 challenges (requires a DNS provider plugin), but Caddy has providers for Route53, Cloudflare, and most major DNS hosts.
Nginx with Certbot isn't terrible — it's just more moving parts. More failure modes. More things to monitor. When cert renewal fails at 3am, it's usually Certbot + cron + file permissions, not Nginx itself.
Verdict: Caddy wins outright. Automatic HTTPS is genuinely zero-maintenance in a way Nginx + Certbot never quite is.
Performance and Architecture
Nginx is written in C. It uses a master process that spawns worker processes (typically one per CPU core), and each worker runs a tight event loop handling thousands of connections with non-blocking I/O. No thread-per-connection overhead. No GC pauses. Just a tight C loop burning through requests.
Caddy is written in Go. Each connection gets a goroutine. Go's scheduler is smart enough that this scales well, but Go does have a garbage collector, and it does have a runtime overhead that C doesn't.
You will find a great many blog posts giving you a precise req/sec figure for this matchup. I went looking for a primary source behind the numbers that circulate and could not find one — what exists is a set of mutually contradictory secondary posts, several of which have Caddy winning by 20%, none of which publish a methodology. So I'm not going to hand you a table I can't stand behind.
The one genuinely reproducible public benchmark I found is Tyler Langlois's "35 Million Hot Dogs: Benchmarking Caddy vs. Nginx" (2022) — k6 as the load generator, two EC2 instances, NixOS-pinned builds for reproducibility, default and tuned configs, a 10-to-10,000 client sweep, and the automation open-sourced. It's dated (nginx ~1.22, Caddy 2.x), so treat it as a real datapoint rather than current truth.
What's safe to say without a fabricated number: nginx is typically modestly faster on raw proxy throughput and runs in a smaller resident footprint, the gap is small enough that it rarely decides an architecture, and results swing wildly with TLS configuration, core count and workload — which is exactly what those contradictory blog posts demonstrate. If throughput is genuinely your deciding factor, you are in the small minority of teams who need to benchmark it on your own hardware, with your own config.
Verdict: Nginx for extreme throughput and minimal memory. For the vast majority of real workloads, the difference is irrelevant and config ergonomics matter more.
HTTP/2 and HTTP/3 Support
Both servers support HTTP/2. HTTP/3 (QUIC) is where they diverge.
Caddy enables HTTP/3 by default when HTTPS is active. No flags, no compile options, no separate module. It just works.
Nginx added QUIC and HTTP/3 support in version 1.25.0 (May 2023), and the official Linux binary packages now include it. But note that nginx's own documentation still labels it experimental — the ngx_http_v3_module page reads "provides experimental support for HTTP/3," with a Known Issues section that says "the module is experimental, caveat emptor applies." That wording is unchanged as of nginx 1.30 (stable) and 1.31 (mainline). Caddy, by contrast, has shipped HTTP/3 on by default for years.
HTTP/3's biggest benefit is on high-latency or lossy connections — mobile networks, international traffic — where QUIC's connection migration and 0-RTT handshake reduce latency meaningfully. If you have global users, this is worth caring about.
# Caddy: HTTP/3 is automatic. Nothing to configure.
api.example.com {
reverse_proxy localhost:3000
}# Nginx: requires explicit listener and quic module
server {
listen 443 quic reuseport;
listen 443 ssl;
http3 on;
add_header Alt-Svc 'h3=":443"; ma=86400';
# ... rest of your ssl_* config
}Verdict: Caddy wins on HTTP/3 ergonomics. Nginx catches up if you're running mainline builds and configure it explicitly.
Dynamic Configuration
Caddy has a first-class JSON API for dynamic configuration. You can add routes, change upstreams, and update TLS settings — all without touching the Caddyfile and without reloading the process.
# Add a new route dynamically via Caddy's API
curl -X POST "http://localhost:2019/config/apps/http/servers/srv0/routes" \
-H "Content-Type: application/json" \
-d '{
"@id": "my-new-route",
"match": [{"host": ["newservice.example.com"]}],
"handle": [{"handler": "reverse_proxy", "upstreams": [{"dial": "localhost:6000"}]}]
}'This makes Caddy genuinely useful as a programmable edge — orchestration systems (Kubernetes controllers, service meshes, deployment pipelines) can update routing config without process restarts.
Nginx's approach is reload-based. nginx -s reload triggers a graceful reload where the old workers finish in-flight requests while new workers pick up the new config. This is fine for most use cases but isn't true zero-downtime for long-running connections (WebSockets, SSE). There are third-party solutions like Nginx Plus's dynamic upstreams or OpenResty, but they're not in the OSS package.
Verdict: Caddy for programmatic control. Nginx for static-ish configs that change infrequently.
Extensibility
Nginx's power comes from its module ecosystem. NJS (Nginx JavaScript), Lua via OpenResty, third-party modules for WAF, auth, rate limiting, caching — the ecosystem is massive. You can do things with Nginx that would require a separate proxy layer with Caddy.
# Nginx + Lua (OpenResty): custom auth middleware
access_by_lua_block {
local token = ngx.var.http_authorization
if not token or not validate_token(token) then
ngx.status = 401
ngx.exit(401)
end
}Caddy's plugin system is cleaner from a developer perspective but smaller. Plugins are Go modules compiled into the binary — so you can't dynamically load them at runtime without rebuilding. The xcaddy tool makes this manageable:
# Build Caddy with custom plugins
xcaddy build \
--with github.com/caddy-dns/cloudflare \
--with github.com/greenpau/caddy-securityIf you need a WAF, advanced rate limiting, or Lua scripting, Nginx (or more specifically OpenResty) is the better option. If you need solid reverse proxy, load balancing, and TLS — Caddy's default set covers 90% of use cases without plugins.
Verdict: Nginx for deep extensibility. Caddy for clean, compile-time plugin composition.
Operational Reality
Nginx has enormous operational gravity. Stack Overflow answers. Runbooks. Colleagues who've debugged it before. It's the default in most Kubernetes ingress controllers (nginx-ingress is the most widely deployed). If something breaks, you'll find someone who's hit the same issue.
Caddy's error messages are more readable. Its documentation is cleaner. A new team member can write a valid Caddyfile on day one. But the community is smaller, the forum answers thinner, and in a 3am incident you'll sometimes be the person writing the Stack Overflow answer.
Caddy stores its TLS certificates in a local data directory — $HOME/.local/share/caddy on Linux (or $XDG_DATA_HOME/caddy if set), and /var/lib/caddy/.local/share/caddy under the official systemd unit, which runs as the caddy user. In containerized environments you need to mount a persistent volume there. Forgetting it means cert re-issuance on every restart, and Let's Encrypt will eventually refuse you.
Get the rate limits right, because the number people quote is the wrong one: it's 50 new certificates per registered domain per 7 days, and separately 5 per week for the exact same set of hostnames (formerly the "duplicate certificate" limit). Restart-looping a single-domain container hits the 5-per-identical-set limit, not a per-domain limit. Since 2025 these are token buckets that refill continuously rather than hard weekly windows, and ARI-driven renewals are exempt entirely.
When to Use Nginx
- You're handling 10,000+ req/sec and raw throughput matters
- Your team has existing Nginx expertise and config management
- You need OpenResty/Lua for request manipulation
- You're using a Kubernetes ingress controller (nginx-ingress is the standard)
- You need Nginx Plus features: session persistence, active health checks, dashboard
- You're integrating with existing infrastructure that assumes Nginx (e.g., certbot cron, config from Ansible)
When to Use Caddy
- You're standing up a new service and want HTTPS without the ceremony
- You're running 1–50 services on a single VM and cert management is a pain
- You're building an internal tool or side project where DX matters more than max throughput
- You need HTTP/3 (QUIC) with zero config effort
- You're building tooling that needs dynamic routing updates via API
- You want multi-domain TLS management without scripts or cron jobs
When to Use Both
On larger systems it's common to see Nginx at the edge (handling raw traffic, terminating TLS at the CDN level, using nginx-ingress in Kubernetes) and Caddy as an internal reverse proxy for service-to-service traffic or for specific teams that manage their own edge. They're not mutually exclusive.
Caddy is what Nginx would look like if it were designed in 2015 with modern defaults. That's a feature and a tradeoff simultaneously. If you're starting fresh, Caddy's automatic HTTPS and clean config are genuinely better defaults for most teams. If you're operating at scale, in an existing Nginx shop, or need the deep extensibility of the module ecosystem, Nginx is still the right call. Choose based on your actual load and operational context, not on which config file you prefer to read.
Comments (0)
No comments yet. Be the first to share your thoughts!