DevLift
Back to Blog

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.

Admin
March 10, 202610 min read2 views

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-renewalCaddy
Maximum raw throughput at extreme scaleNginx
Minimal config for a side project or internal toolCaddy
Nginx Plus features (enterprise load balancing, active health checks)Nginx
HTTP/3 (QUIC) out of the boxCaddy
A battle-tested server your ops team already knowsNginx
Dynamic config via a JSON APICaddy
Fine-grained module control (OpenResty, Lua, etc.)Nginx
Docker-based routing with simple labelsEither (Caddy wins on simplicity)
Multi-tenant certificate management for 100+ domainsCaddy

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.

⚠️
What Caddy does not do for you is set security headers. I checked the actual response: with automatic HTTPS active, Caddy adds 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
    }
}
⚠️
Don't mix 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:

  1. On startup, it identifies all domains in your config
  2. It performs ACME challenges (HTTP-01 by default, DNS-01 if configured) with Let's Encrypt or ZeroSSL
  3. Certs are stored locally and renewed before they expire
  4. 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

Rendering diagram...

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-security

If 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!

Related Articles

A monotonic stack maintains elements in order and pops when that order breaks — finding the next greater element for every popped value in O(n) total.
AdminAugust 3, 20265 min read
The EventEmitter pattern lets components in the same process react to the same event without being directly coupled — no message broker needed.
AdminAugust 3, 20266 min read
One file to guard every route — plus the Next.js 16 rename that moves it off the Edge runtime, the request-vs-response header trap that leaks user IDs to the browser, and the CVE that explains why this can never be your only auth layer.
AdminAugust 3, 20268 min read