·11 min read·DeepSeek

Caddy vs nginx as a reverse proxy: which do I actually reach for?

A hands-on Caddy vs nginx comparison for reverse proxy work: the same job written in both, real curl -I output, HTTP/3 and compression, TLS certificate handling (automatic HTTPS vs certbot), config-size and ergonomics, and a decision list.

nginxdevopsnetworking

Every few months someone asks whether they should drop nginx for Caddy, and the answers online are usually tribal. I run both. nginx is the incumbent I have configured for a decade; Caddy is the newcomer I reach for when the job is “one service, one certificate, no ceremony”. To make the Caddy vs nginx question concrete, I put the exact same reverse-proxy job behind each — a tiny origin app, both proxies in front, both configs from scratch — and kept every real response. I compared an nginx reverse proxy and a Caddy reverse proxy serving identical traffic, and let automatic HTTPS do its thing on one side while I wired certificates by hand on the other. Nothing below is a benchmark I invented; it is curl output from throwaway containers on localhost.

If you want the deeper nginx mechanics, I keep them in the nginx config cookbook; the caching headers these proxies emit are the subject of HTTP caching explained.

Caddy vs nginx: incumbent vs ergonomic newcomer

nginx is the default. It is in every distro, every Kubernetes ingress controller, every tutorial, and every colleague’s memory. That is not a small thing — it means the person debugging your proxy at 2 a.m. probably already knows it. Caddy’s bet is different: it assumes the hard part is not throughput but configuration and certificates. It ships with automatic HTTPS (ACME built in, on by default), HTTP/3, sane security defaults, and a config language that fits a one-service deployment on a napkin.

So the real question is not “which is faster” — for one app behind a proxy, both are vastly faster than your app needs. It is “which failure mode do I want to own”: TLS renewal and module drift, or a smaller ecosystem.

The same job as an nginx reverse proxy

The task: terminate TLS for localhost, proxy everything to app:5678, forward the client IP, compress responses. Here is the nginx side, running as nginx/1.27.5:

$ docker run -d --name ngx --network rpnet \
    -p 127.0.0.1:18080:80 \
    -v "$PWD/nginx.conf":/etc/nginx/conf.d/default.conf:ro \
    nginx:1.27-alpine
$ docker exec ngx nginx -v
nginx version: nginx/1.27.5
server {
    listen 80;
    server_name _;

    location / {
        proxy_pass http://app:5678;
        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_http_version 1.1;
    }
}

That is 13 lines and 344 bytes for the HTTP version. Note how much of it is you telling the proxy things it could reasonably assume: which headers to forward, which HTTP version to speak upstream. Nothing here is wrong — but every line is a decision you own, and forgetting X-Forwarded-For silently breaks rate limiting and logging downstream.

The same job as a Caddy reverse proxy

The Caddyfile, running as caddy:2-alpine, expresses the same intent:

$ docker run -d --name cdy --network rpnet \
    -p 127.0.0.1:18443:443 -p 127.0.0.1:18480:80 \
    -v "$PWD/Caddyfile":/etc/caddy/Caddyfile:ro \
    caddy:2-alpine
$ docker exec cdy caddy version
v2.11.7 h1:yj0Y4fYZGPkSvibBJ1sTWE33xC0fxztVyXEW5iIdUTI=
{
	admin 127.0.0.1:2019
}

https://localhost {
	tls internal
	encode gzip zstd
	reverse_proxy app:5678 {
		header_up X-Real-IP {remote_host}
	}
}

Eleven lines, 146 bytes — and the actual reverse-proxy job is one line. Caddy infers the scheme from https://, turns on compression, sets the standard forwarding headers, and serves HTTP/3 on the same listener. The tls internal line is the only reason this is not, on a public domain, a free Let’s Encrypt certificate fetched automatically. The admin block only exists so I can poke the JSON API later.

Real responses: what each proxy sends back

Here is what a client actually sees. nginx on plain HTTP first:

$ curl -sS -I http://127.0.0.1:18080/
HTTP/1.1 200 OK
Server: nginx/1.27.5
Date: Mon, 12 Oct 2026 09:09:01 GMT
Content-Type: application/json
Content-Length: 186
Connection: keep-alive

And Caddy over its internally-issued TLS, same backend:

$ curl -sS -k -I https://localhost:18443/
HTTP/2 200
alt-svc: h3=":443"; ma=2592000
content-type: application/json
date: Mon, 12 Oct 2026 09:09:01 GMT
server: BaseHTTP/0.6 Python/3.12.14
via: 1.0 Caddy
content-length: 186

Two things jump out. Caddy answered over HTTP/2 and advertised HTTP/3 with alt-svc: h3=":443" — out of the box, no config. nginx on plain HTTP answered HTTP/1.1, because http2 is opt-in: adding http2 on; to the server block gets you HTTP/2 200, but only after you know to add it. HTTP/3 on nginx needs a QUIC-capable build plus a separate listen ... quic reuseport; UDP block; Caddy ships it.

HTTP/3 is not marketing here. Asking curl for it directly returns HTTP/3, not a fallback:

$ curl -sS --http3-only -k -o /dev/null \
    -w 'http_version=%{http_version} code=%{response_code}\n' \
    https://localhost:18443/
http_version=3 code=200

Compression behaves the same way — one directive, real bytes:

$ curl -sS -k -D - -o /dev/null -H 'Accept-Encoding: gzip' \
    'https://localhost:18443/big'
HTTP/2 200
content-encoding: gzip
vary: Accept-Encoding
content-length: 173

The origin also confirms which forwarded headers each proxy adds. nginx (configured by me):

$ curl -sS http://127.0.0.1:18080/demo
{
  "host": "127.0.0.1",
  "x_forwarded_for": "172.24.0.1",
  "x_forwarded_proto": "http",
  "x_real_ip": "172.24.0.1"
}

Caddy (default headers plus my one header_up):

$ curl -sS -k https://localhost:18443/demo
{
  "host": "localhost:18443",
  "x_forwarded_for": "172.24.0.1",
  "x_forwarded_proto": "https",
  "x_real_ip": "172.24.0.1"
}

Both sets are correct. The difference is that nginx’s is correct because I listed it, and Caddy’s is correct because that is the default.

TLS certificate handling: automatic HTTPS vs certbot

This is the clearest divergence. Caddy issued its own certificate on first start, with no command run, and told me where it lives:

$ docker exec cdy find /data/caddy/certificates -type f
/data/caddy/certificates/local/localhost/localhost.crt
/data/caddy/certificates/local/localhost/localhost.key
/data/caddy/certificates/local/localhost/localhost.json
$ echo | openssl s_client -connect 127.0.0.1:18443 \
    -servername localhost 2>/dev/null | openssl x509 -noout -issuer
issuer=CN=Caddy Local Authority - ECC Intermediate

On a public domain that tls internal line is the only thing that changes: Caddy speaks ACME to Let’s Encrypt, completes the challenge, stores and renews the certificate, and redirects HTTP to HTTPS — which it already does for the internal case:

$ curl -sS -I http://127.0.0.1:18480/
HTTP/1.1 308 Permanent Redirect
Location: https://127.0.0.1/
Server: Caddy

nginx does none of this itself. To serve TLS it needs explicit key material and a couple more directives, and on the outside it needs an ACME client (certbot, acme.sh, lego) plus a renewal timer. I fed nginx the certificate Caddy had just generated, which is a fair stand-in for “cert files on disk”:

server {
    listen 443 ssl;
    http2 on;
    server_name localhost;

    ssl_certificate     /etc/nginx/certs/localhost.crt;
    ssl_certificate_key /etc/nginx/certs/localhost.key;

    location / {
        proxy_pass http://app:5678;
        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;
    }
}
$ curl -sS -k -I https://localhost:18444/
HTTP/2 200
server: nginx/1.27.5
content-type: application/json
content-length: 186

Sixteen lines now, and still no certificate issuance, renewal, or HTTP→HTTPS redirect — those live in your certbot deploy hooks and your calendar. That is not a knock; it is a deliberate separation of concerns. It is also real operational surface: the number of ways a hand-wired renewal fails quietly is the reason I wrote about automated TLS at scale.

Config-language ergonomics: Caddy vs nginx

nginx’s language is a directive tree with scopes and inheritance rules you have to hold in your head — the add_header inheritance trap in my nginx cookbook is a good example of a rule that bites even experienced users. It is expressive and fast, but it is a language to learn.

Caddy’s is closer to a sentence: reverse_proxy app:5678 does what it says. When you need to see what it compiles to, the JSON is right there, and you can read it:

$ docker exec cdy wget -qO- http://127.0.0.1:2019/config/ | head -c 200
{"admin":{"listen":"127.0.0.1:2019"},"apps":{"http":{"servers":{"srv0":{
"listen":[":443"],"routes":[{"handle":[{"handler":"subroute",...

That same JSON is a live API: caddy reload swapped config with no restart, and caddy validate / nginx -t both give you a pre-flight check.

$ docker exec ngxh2 nginx -t
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
$ docker exec cdy caddy validate --config /etc/caddy/Caddyfile | tail -1
Valid configuration
Same job, three configs (lines) nginx · HTTP nginx · HTTPS Caddy · HTTPS 13 lines / 344 B 16 lines 11 lines / 146 B solid bar = whole file · light bar = for scale Caddy's reverse_proxy itself is one line; nginx spends five on headers alone.
Config size for the identical reverse-proxy task, counted from the files above (wc -c -l). Line count is not quality — it is the amount of surface you own and can get wrong.

Where Caddy wins

  • Automatic HTTPS. ACME issuance, renewal, and HTTP→HTTPS redirects are defaults, not add-ons. The 90-day-renewal-never-fires class of outage mostly disappears.
  • Tiny, readable config. One line for the common case; the abstraction usually matches your intent on the first try.
  • Modern defaults. HTTP/3 with alt-svc advertised, HTTP/2, gzip/zstd, and sensible security headers without a checklist.
  • A real JSON API. Live config reload, programmatic configuration, and machine-readable state — genuinely nice when something else manages the proxy.
  • One binary. No module set to reconcile with your distro’s build.

Where nginx wins

  • Ubiquity. It is everywhere, so the help is everywhere, including the person next to you and every Stack Overflow answer from the last decade.
  • Raw performance and tuning. nginx’s worker/event model, buffering, caching, and upstream controls are deeper and more studied. If you are pushing serious load through one box, that depth is the product.
  • Modules and ecosystem. Stream (TCP/UDP) proxying, Lua, njs, third-party modules, and the ingress controllers built on top of it.
  • Maturity. Two decades of edge cases already debugged. Caddy is stable but younger, and its ecosystem is smaller.
  • Predictability at scale. Teams already standardized on nginx; introducing a second proxy has a cost.

Which do I actually reach for? A decision list

  • One box, a few services, you want TLS to just work? Caddy. This is the case it was built for.
  • You need automatic certificates across many names without external tooling? Caddy; it is less wiring than certbot plus deploy hooks.
  • You want HTTP/3 today with no extra build steps? Caddy.
  • You need Lua/njs, stream proxying, or a specific module? nginx.
  • You are tuning for the last few percent of throughput, or under very high concurrency? nginx.
  • Your team, runbooks, and monitoring all assume nginx? Stay on nginx. Consistency beats elegance.
  • You are already happy with certbot and a renewal timer? There is no reason to migrate. “Caddy is nicer” is not, by itself, a reason to rewrite a working config.
  • Learning one of them for the first time? Learn nginx, because you will meet it; learn Caddy, because it teaches you what good defaults look like.

That is the honest answer: I still reach for nginx when I need its depth or the team already speaks it, and for everything new and small, I reach for Caddy and let automatic HTTPS do the part I used to automate by hand.