Caddy as a Reverse Proxy: Why I Chose It Over Nginx

What automatic HTTPS actually does under the hood, the Caddyfile syntax that matters, reverse proxy recipes for real applications, the gotchas that cost me time - and an honest account of where Nginx is still the better tool.

Nginx is the default. It runs a large share of the web, the documentation is exhaustive, and every deployment guide written in the last fifteen years assumes it.

I use Caddy instead, on a personal VPS hosting a few projects behind a single proxy. This is why, what it actually takes to configure it, and the situations where I would still reach for Nginx.

The Argument in One Comparison

Serving one application over HTTPS with a redirect from HTTP.

Nginx, with Certbot already installed and configured:

server {
    listen 80;
    server_name example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;
    ssl_protocols       TLSv1.2 TLSv1.3;
    ssl_ciphers         HIGH:!aNULL:!MD5;
    ssl_session_cache   shared:SSL:10m;

    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;
    }
}

Plus a Certbot installation, an ACME challenge path that does not conflict with the redirect, and a renewal timer that reloads Nginx afterwards.

Caddy:

example.com {
	reverse_proxy localhost:3000
}

That is the complete configuration. Certificate acquisition, renewal, the HTTP→HTTPS redirect, HTTP/2, OCSP stapling, modern TLS defaults, and every proxy header in the Nginx block above — all of it is default behaviour.

The Nginx version is not badly written. That is roughly the minimum for a correct, secure setup, which is the point. Caddy’s argument is not that it does more. It is that the secure, correct configuration is the one you get by writing nothing.

What Automatic HTTPS Actually Does

Worth understanding rather than treating as magic, because when it fails you need to know what it was attempting.

When Caddy sees a site block with a public domain name, it:

  1. Finds an ACME provider. Let’s Encrypt by default, with ZeroSSL as automatic fallback if issuance fails.
  2. Solves a challenge to prove you control the domain. It tries TLS-ALPN-01 over port 443 first, falling back to HTTP-01 over port 80. Both need to be reachable from the internet.
  3. Stores the certificate in its data directory, along with the ACME account key.
  4. Renews at roughly two-thirds of the lifetime — around 30 days before expiry — in a background goroutine. No cron, no timer, no reload.
  5. Staples OCSP responses and refreshes them on its own.

It also sets up an HTTP site that redirects to HTTPS, which is why you never write a redirect block.

For a domain that is not public — localhost, or an internal name — Caddy issues from its own local CA instead and offers to install the root into your system trust store. Local development gets real HTTPS with no self-signed certificate warnings.

Where That Data Lives

This is the single most important operational detail, and the one that bites hardest.

Certificates, private keys, and ACME account state live in Caddy’s data directory:

$XDG_DATA_HOME/caddy      # usually ~/.local/share/caddy
/data                     # inside the official container image

If that directory is not persistent, every restart discards your certificates and requests new ones.

Let’s Encrypt enforces a limit of five duplicate certificates per week. You can burn through that surprisingly fast while debugging something unrelated, and then you are locked out of issuing for that domain for days — with a perfectly working server and no way to get a certificate onto it.

In a container, mount a named volume at /data before you start it the first time. Not after.

Caddyfile Syntax Worth Knowing

Site Blocks

The address at the top of a block determines everything — including whether HTTPS is automatic.

example.com {
	reverse_proxy localhost:3000
}

# multiple domains, one block
example.com, www.example.com {
	reverse_proxy localhost:3000
}

# wildcard subdomains (needs a DNS challenge)
*.example.com {
	reverse_proxy localhost:3000
}

# HTTP only — no certificate attempted
http://example.com {
	respond "insecure"
}

# a specific port
:8080 {
	respond "no TLS here"
}

The Global Block

An unnamed block at the very top of the file, before any site:

{
	email you@example.com
	# staging endpoint — use this while testing to avoid rate limits
	# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
}

That commented line is worth uncommenting whenever you are experimenting with a new setup. The staging endpoint issues untrusted certificates with far looser rate limits. Test against it, then switch back.

Directive Order Does Not Match File Order

The one genuine surprise in Caddyfile syntax. Directives execute in a predefined order, not the order you wrote them. redir runs before reverse_proxy regardless of position, because that is what the order table says.

Most of the time this does what you wanted anyway. When it does not, wrap directives in a route block to force sequential execution:

example.com {
	route {
		redir /old /new
		reverse_proxy localhost:3000
	}
}

Snippets

Reusable blocks, defined with parentheses:

(security) {
	header {
		Strict-Transport-Security "max-age=31536000; includeSubDomains"
		X-Content-Type-Options "nosniff"
		X-Frame-Options "SAMEORIGIN"
		Referrer-Policy "strict-origin-when-cross-origin"
		-Server
	}
}

example.com {
	import security
	reverse_proxy localhost:3000
}

blog.example.com {
	import security
	reverse_proxy localhost:3001
}

The -Server line removes the Server header. Small thing, but there is no reason to advertise what you run.

Reverse Proxy Recipes

The Headers You Get for Free

reverse_proxy automatically sets X-Forwarded-For, X-Forwarded-Proto, X-Forwarded-Host, and preserves the Host header. It also handles WebSocket upgrades with no extra configuration — the Upgrade and Connection dance that Nginx needs three lines for is simply not something you write.

Multiple Applications

example.com {
	reverse_proxy localhost:3000
}

api.example.com {
	reverse_proxy localhost:4000
}

blog.example.com {
	reverse_proxy localhost:5000
}

If your backends are containers on a shared network, use their names instead of ports on localhost:

example.com {
	reverse_proxy web:3000
}

That is what I do — the backends publish no host ports at all, so the only way to reach them is through the proxy. There is no port to firewall because there is no port.

Path-Based Routing

example.com {
	handle /api/* {
		reverse_proxy api:4000
	}

	handle {
		reverse_proxy web:3000
	}
}

handle blocks are mutually exclusive — the first match wins — which makes them behave the way people usually expect Nginx location blocks to behave.

Static Files and SPAs

example.com {
	root * /srv
	encode zstd gzip
	try_files {path} /index.html
	file_server
}

try_files with a fallback to index.html is the standard single-page-app rewrite.

Load Balancing and Health Checks

example.com {
	reverse_proxy app1:3000 app2:3000 app3:3000 {
		lb_policy least_conn
		health_uri /health
		health_interval 10s
		fail_duration 30s
	}
}

Unhealthy backends are pulled out of rotation automatically.

Getting the Real Client IP

If something sits in front of Caddy — Cloudflare, a load balancer — you must tell it which proxies to trust, or you will log the proxy’s address instead of the visitor’s:

example.com {
	reverse_proxy web:3000 {
		trusted_proxies static 10.0.0.0/8 172.16.0.0/12
	}
}

Do not trust blindly. An untrusted client can set X-Forwarded-For to anything it likes, and blanket trust turns your access logs into fiction.

Basic Auth

caddy hash-password
admin.example.com {
	basic_auth {
		admin $2a$14$hashedpasswordgoeshere
	}
	reverse_proxy dashboard:8080
}

Note that the hash goes in the config, never the password.

Reload, Do Not Restart

caddy reload --config /etc/caddy/Caddyfile

Or, in a container:

podman exec caddy caddy reload --config /etc/caddy/Caddyfile

This applies configuration changes with zero downtime. No dropped connections, no restart, no brief window where the site is unreachable. Caddy validates the new config first and keeps running the old one if it fails.

Validate separately before applying, if you like:

caddy validate --config /etc/caddy/Caddyfile

There is very little reason to ever restart Caddy for a config change, and every restart is a chance to lose something you did not mean to.

Gotchas

Persist the data directory. Covered above, and it is the expensive one. Rate limits have a cooldown measured in days.

DNS must resolve before the first start. Both ACME challenges require the domain to point at your server and the relevant port to be reachable. Check with dig +short example.com from the server itself before starting Caddy, not after wondering why issuance failed.

Both port 80 and 443 need to be open. People close 80 thinking HTTPS-only is more secure. HTTP-01 validation needs it, and so does the redirect. Leave it open.

Wildcard certificates need a DNS challenge, which needs a provider-specific plugin and therefore a custom Caddy build:

xcaddy build --with github.com/caddy-dns/cloudflare

The stock binary and stock container image cannot do wildcards. Worth knowing before you plan around them.

Caddyfile formatting is enforced. Run caddy fmt --overwrite and stop thinking about it.

Logs are JSON by default. Structured and machine-readable, but harder to skim. For a small server:

example.com {
	log {
		format console
	}
	reverse_proxy web:3000
}

Where Nginx Still Wins

I would not tell anyone to migrate a working Nginx setup. The honest cases:

Raw static file throughput at scale. Nginx is faster serving static files under heavy concurrent load. It has had two decades of optimisation for exactly that. For a personal site the difference is unmeasurable, but at genuine scale it is real.

Memory footprint. Caddy is Go with a garbage collector and uses noticeably more memory at idle. On a 512 MB VPS that matters.

The module ecosystem. Nginx has modules for everything — RTMP streaming, Lua scripting, obscure protocols. Caddy’s plugin ecosystem is smaller, and extending it means rebuilding the binary with xcaddy.

Fine-grained control. If you need precise buffer tuning, connection limits, complex caching behaviour, or unusual protocol handling, Nginx exposes more knobs. Caddy’s opinionated defaults are a feature until the day you need to override one that is not exposed.

Existing knowledge and tooling. If your team knows Nginx, your monitoring parses its logs, and your config management deploys it, that is a real asset. Switching costs more than the config file length suggests.

You already have certificate infrastructure. If certificates come from a corporate CA or an existing ACME pipeline, Caddy’s headline feature is one you do not need.

Compliance requirements on TLS parameters. Caddy deliberately restricts TLS tuning to sane options. If an auditor wants a specific ciphersuite ordering, that constraint becomes a problem.

How I Decided

The criteria that actually mattered on a personal server:

Certificate management is the highest-risk recurring task, and it is the one most likely to fail silently and take a site down at a bad moment. Removing it entirely, rather than automating it with a second tool, eliminated the failure mode instead of managing it.

Config length correlates with mistakes. Six lines I fully understand beat forty lines I copied from a blog post, only some of which I could explain.

Defaults are the configuration most people actually run. Caddy’s defaults are modern TLS, HTTP/2, and correct proxy headers. Nginx’s defaults are whatever your distribution decided, plus whatever you pasted in.

Nothing about my traffic is near the scale where Nginx’s performance advantages appear. Optimising for a scale I do not have would be choosing complexity for imaginary reasons.

If any of those weighed differently for you — a large static site, a tight memory budget, an existing Nginx deployment that works — the other answer is the right one. They are both excellent servers. They just optimise for different problems, and mine was “stop thinking about certificates.”


Related: Setting Up a VPS, Part 4 walks through this running as a rootless container in front of real applications, and Podman Quadlet covers the container side.

© 2026 r3p.dev. All rights reserved.