Most teams we know run Nginx in front of their apps because it was there first, not because they compared it with anything. When we set up a new environment for a dozen small internal and customer-facing services this year, we asked the question properly: could Caddy do the job with less configuration and fewer certificate chores, and what would we give up?
The short answer was yes for most of our services and no for two of them. This post walks through the comparison we did, with configuration side by side, so you can run the same evaluation for your own stack. Versions referenced: Caddy 2.10 and Nginx 1.28 stable.
The headline difference: certificates
With Nginx, HTTPS is something you assemble. You install certbot or another ACME client, obtain certificates, point ssl_certificate at the files, set up renewal with a timer, and reload Nginx after renewal. Each piece is simple, and each is a place where an expired certificate can come from. In 2025 Nginx gained an official ACME module, distributed as a separately built dynamic module; it is promising, but it is new and we did not want our renewal path to depend on it yet.
With Caddy, HTTPS is the default. Any site address that is a public domain name gets a certificate from Let's Encrypt or ZeroSSL automatically, renewals happen in the background well before expiry, HTTP is redirected to HTTPS, and modern TLS settings are applied without configuration. For internal names and localhost, Caddy issues certificates from its own local CA.
For a fleet of small services, this one difference removed a whole category of tickets for us.
Configuration side by side
A typical service: TLS, compression, two upstream instances with health checks, security headers, and a larger upload limit for one path.
Nginx:
upstream app {
least_conn;
server 127.0.0.1:3000 max_fails=3 fail_timeout=10s;
server 127.0.0.1:3001 max_fails=3 fail_timeout=10s;
}
server {
listen 80;
server_name app.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
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;
ssl_protocols TLSv1.2 TLSv1.3;
gzip on;
gzip_types text/css application/javascript application/json;
add_header Strict-Transport-Security "max-age=31536000" always;
client_max_body_size 1m;
location /uploads/ {
client_max_body_size 50m;
proxy_pass http://app;
include /etc/nginx/proxy_params;
}
location / {
proxy_pass http://app;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
include /etc/nginx/proxy_params;
}
}
Caddy:
app.example.com {
encode zstd gzip
header Strict-Transport-Security "max-age=31536000"
@uploads path /uploads/*
@other not path /uploads/*
request_body @uploads {
max_size 50MB
}
request_body @other {
max_size 1MB
}
reverse_proxy 127.0.0.1:3000 127.0.0.1:3001 {
lb_policy least_conn
health_uri /healthz
health_interval 10s
}
}
The Caddy version has no certificate paths, no redirect block and no WebSocket headers, because all three are default behaviour. reverse_proxy handles connection upgrades on its own and sets X-Forwarded-For, X-Forwarded-Proto and X-Forwarded-Host. It also has active health checks built in; in open source Nginx you get passive checks (max_fails) only.
The Nginx version is more explicit, and some people prefer that. Nothing happens that you did not write down.
Performance: what we actually measured
We ran the same small Node.js service behind each proxy on a 2 vCPU VM and drove it with a load generator from a second VM, over HTTPS with keep-alive. What we saw, and what we would expect most teams to see:
- Throughput and latency were dominated by the application. At the request rates our services see, both proxies added well under a millisecond at the median, and neither was the bottleneck.
- Nginx used less memory and less CPU per request. At our highest test rate, Nginx's CPU usage was noticeably lower. Caddy is written in Go and its memory grows with concurrency and garbage collection; Nginx's event loop in C is very lean.
- Tail latency under heavy concurrency was slightly better with Nginx in our runs.
If you serve very high request rates from a single proxy tier, or you run on tiny instances where every megabyte counts, Nginx's efficiency matters. For most services we run, the difference was not visible in user-facing metrics. Run your own test with your traffic shape before trusting anyone's benchmark, including ours.
What Caddy does better
- Automatic HTTPS and renewal, including for many domains at once.
- On-demand TLS. For a SaaS that lets customers bring their own domain, Caddy can obtain a certificate at the first TLS handshake for a new hostname. You must configure the
askendpoint so Caddy only does this for domains your application approves; otherwise anyone can point a domain at you and make you request certificates. - HTTP/3 by default. Caddy serves HTTP/3 on UDP 443 without extra configuration. Nginx supports it with
listen 443 quic;and anAlt-Svcheader since 1.25, but you have to enable it. - Admin API. Caddy exposes a JSON API (by default on
localhost:2019) to load and change configuration without restarts. Keep it bound to localhost or disable it. - Readable config for simple cases. New team members could read a Caddyfile on the first day.
What Nginx still does better
- Caching.
proxy_cacheis mature and fast. Caddy needs a third-party cache module, which means building a custom binary. - Rate limiting and connection limiting.
limit_reqandlimit_connare built in. Caddy's rate limiting is a plugin, again requiring a custom build withxcaddy. - Ecosystem. OpenResty and Lua, njs, a huge number of documented configurations, and every hosting and monitoring tool knowing its log format.
- Resource efficiency at scale, as above.
Those are exactly why we kept Nginx for two services: one relies heavily on proxy_cache for expensive API responses, and one needs fine-grained per-key rate limiting.
Migration traps we hit
| Trap | What happened | What we do now |
|---|---|---|
| Certificate storage not persistent | Caddy in a container without a volume for /data requested new certificates on every restart and ran into ACME rate limits |
Mount /data and /config as volumes, always |
| Client IP behind a load balancer | Logs and rate limits saw the load balancer's IP | Configure trusted_proxies in global options so Caddy uses the real client address |
| Body size defaults differ | Nginx rejects bodies over 1 MB by default; Caddy has no limit unless set | Set request_body max_size explicitly to keep the same protection |
| Timeouts | Long-running export endpoints behaved differently | Set transport http timeouts in reverse_proxy explicitly, as we had with proxy_read_timeout |
| Port 80 closed | ACME HTTP-01 challenges failed because a firewall only allowed 443 | Allow 80 or rely on the TLS-ALPN challenge on 443 |
| Multiple instances | Two Caddy nodes both tried to manage the same certificates | Use shared storage for the certificate data so instances coordinate |
We validated each Caddyfile before deploying, which catches syntax and adaptation errors early:
caddy validate --config /etc/caddy/Caddyfile
caddy fmt --overwrite /etc/caddy/Caddyfile
How we decided per service
Our rule of thumb after the exercise:
- Choose Caddy for many small sites and services, customer custom domains, teams without a dedicated person for edge configuration, and anywhere certificate expiry has bitten you before.
- Keep Nginx where you depend on
proxy_cache, complex rate limiting, Lua or njs logic, or where proxy efficiency at very high request rates is a measured need. - Either works for a plain reverse proxy in front of an application with moderate traffic. Pick the one your team will maintain well.
Key takeaways
- Caddy's main advantage is operational: certificates, renewal, redirects and HTTP/3 with no configuration.
- Nginx remains stronger for caching, rate limiting, extensibility and raw efficiency.
- In our tests, application latency dwarfed proxy overhead for both; measure with your own traffic.
- When migrating, persist Caddy's data directory and set body limits, timeouts and trusted proxies explicitly.
- Protect on-demand TLS with an
askendpoint and keep the admin API on localhost.
References
- Caddy automatic HTTPS: https://caddyserver.com/docs/automatic-https
- Caddy reverse_proxy directive: https://caddyserver.com/docs/caddyfile/directives/reverse_proxy
- Caddy global options: https://caddyserver.com/docs/caddyfile/options
- Nginx proxy module: https://nginx.org/en/docs/http/ngx_http_proxy_module.html
- Nginx HTTP/3 support: https://nginx.org/en/docs/quic.html
- Nginx limit_req module: https://nginx.org/en/docs/http/ngx_http_limit_req_module.html
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.