
Most of us have performed the ritual. Spin up a server. Install nginx. Install certbot. Add a cron job, or a systemd timer. Write a deploy hook so nginx reloads after renewal. Then, six months later at 2 a.m., discover the hook has been silently failing and your certificate expired an hour ago.
Here is the Caddy version of that ritual:
example.com {
reverse_proxy localhost:3000
}That's the whole thing. You get a real, trusted TLS certificate, an automatic HTTP-to-HTTPS redirect, HTTP/2 and HTTP/3, background renewals, and OCSP stapling. No cron job, no hook, no second tool.
This post is a tour of how Caddy pulls that off, the patterns that matter in real deployments, and the places where it will bite you if you aren't ready for it.
Why this matters more every year
TLS used to be something you did once and forgot about for a year or two. That era is ending. The industry has agreed to phase maximum public certificate lifetimes down over the next few years, toward just 47 days by 2029. When certificates live for weeks instead of years, manual renewal stops being an annoyance and becomes an outage waiting to happen.
Caddy was built around the opposite assumption: that certificate management is the server's job. It was the first widely used web server to make HTTPS automatic and on by default, and everything else about its design follows from the same philosophy: sensible defaults, secure by default, minimal ceremony.
What Caddy actually is
Caddy is an open-source (Apache 2.0) web server and reverse proxy written in Go, created by Matt Holt and first released in 2015. A few properties define it:
A single static binary. No runtime, no dependency tree, no package-manager archaeology. Copy it to a machine and run it.
Memory-safe by construction. Being written in Go removes a whole class of vulnerabilities that C-based servers have to defend against.
Modular to the core. Since the Caddy 2 rewrite in 2020, nearly everything (HTTP handlers, TLS issuers, log encoders, storage backends) is a module you can swap or extend.
JSON underneath, Caddyfile on top. The native config is JSON, driven through a live API. The Caddyfile is a human-friendly adapter that compiles down to that JSON.
That last point is the key to understanding Caddy. You get a config format a tired human can read at midnight, backed by a machine-friendly API you can automate when you outgrow hand-editing.
Automatic HTTPS, demystified
"Automatic HTTPS" sounds like marketing until you see what actually happens when Caddy starts with a hostname in its config:
It notices a public domain name in your site address.
It redirects HTTP to HTTPS on port 80 for that site.
It obtains a certificate using the ACME protocol, from Let's Encrypt or ZeroSSL, and falls back to the other if one fails.
It renews in the background, well before expiry, with no restart and no dropped connections.
It staples OCSP responses and keeps them fresh.
It stores everything in a data directory (or a shared storage backend, if you run a cluster).
The requirements are modest: your domain's DNS must point at the server, and ports 80 and 443 must be reachable so the CA can validate you. If they can't be (a private network, a wildcard certificate), you use the DNS challenge instead, covered below.
Local development gets real HTTPS too
For localhost, *.localhost and IP addresses, Caddy can't get a public certificate, so it uses a built-in local certificate authority instead. Run caddy trust once to install its root certificate in your system trust store, and your dev environment serves genuine HTTPS with no browser warnings:
myapp.localhost {
reverse_proxy localhost:3000
}Parity between development and production is a quiet superpower. Cookie flags, mixed-content bugs, service workers and secure-context APIs all behave the way they will in production.
On-demand TLS: certificates at request time
If you run a SaaS where customers point their own domains at you, Caddy can issue a certificate the first time it sees a handshake for a new hostname:
{
on_demand_tls {
ask http://localhost:5555/check
}
}
https:// {
tls {
on_demand
}
reverse_proxy localhost:3000
}The ask endpoint is not optional in practice. It's how you tell Caddy "yes, this domain belongs to one of our customers." Without it, anyone can point random domains at your server and burn through your CA rate limits.
The Caddyfile: config you can read
The Caddyfile's design principle is that common things should be short and obvious. Here are the recipes you'll reach for most.
A static site
example.com {
root * /var/www/example
encode zstd gzip
file_server
}root sets the directory, encode turns on compression, file_server serves files. Each line does one thing, and the names say what it is.
A single-page app with an API behind it
example.com {
encode zstd gzip
handle /api/* {
reverse_proxy localhost:8080
}
handle {
root * /var/www/app
try_files {path} /index.html
file_server
}
}handle blocks are mutually exclusive, like an if/else chain: the first match wins. API traffic goes to your backend, and everything else falls through to the SPA with a client-side routing fallback. If your backend expects paths without the /api prefix, swap handle for handle_path, which strips the matched prefix before proxying.
Reusing config with snippets
As you add sites, repetition creeps in. Snippets fix that:
(security) {
header {
Strict-Transport-Security max-age=31536000
X-Content-Type-Options nosniff
Referrer-Policy strict-origin-when-cross-origin
-Server
}
}
app.example.com {
import security
reverse_proxy app:3000
}
docs.example.com {
import security
root * /var/www/docs
file_server
}Note that Caddy does not add HSTS for you. That's a deliberate choice, since HSTS is hard to undo, so you opt in once you're sure.
Load balancing with health checks
api.example.com {
reverse_proxy 10.0.0.11:8080 10.0.0.12:8080 10.0.0.13:8080 {
lb_policy least_conn
health_uri /healthz
health_interval 10s
health_timeout 2s
}
}List more than one upstream and Caddy load balances. You choose the policy (round robin, least connections, IP hash, and others), and active health checks pull unhealthy backends out of rotation until they recover. Caddy also does passive health tracking and retries on its own.
Wildcard certificates with the DNS challenge
Wildcards require proving control of the DNS zone, which means a DNS provider plugin:
*.example.com {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
@app host app.example.com
handle @app {
reverse_proxy localhost:3000
}
@admin host admin.example.com
handle @admin {
reverse_proxy localhost:4000
}
handle {
abort
}
}Three things are worth noticing. The @name lines are named matchers, Caddy's way of saying "only apply this to requests that look like that." {env.CF_API_TOKEN} pulls a secret from the environment instead of hard-coding it. And the final handle { abort } drops connections for any hostname you didn't explicitly list, which is a good default for a wildcard.
Quick one-liners
Sometimes you don't want a config file at all:
# Serve the current directory with a file browser
caddy file-server --browse
# Put HTTPS in front of something on port 3000
caddy reverse-proxy --from example.com --to localhost:3000These are wonderfully handy for demos, quick internal tools and "I just need this reachable right now" moments.
Zero-downtime config and the admin API
Caddy listens on localhost:2019 for its admin API. You can read the live config, change it, and apply it without dropping a single connection:
# Validate before you ship
caddy validate --config /etc/caddy/Caddyfile
# Apply a new config gracefully
caddy reload --config /etc/caddy/Caddyfile
# See the JSON your Caddyfile compiles to
caddy adapt --config /etc/caddy/Caddyfile --pretty
# Inspect the live config
curl localhost:2019/config/
# Auto-format your Caddyfile
caddy fmt --overwrite /etc/caddy/CaddyfileIf the new config is invalid, the reload is rejected and the old one keeps serving. That safety net is worth a lot on a busy server. The admin endpoint is bound to localhost by default, so leave it that way unless you have a good reason and proper access control.
Extending Caddy with modules
The standard download covers most needs, but things like DNS-provider integrations, extra caches, rate limiters and custom auth live in third-party modules. Because Caddy is a single static binary, you add them by building a custom one with xcaddy:
xcaddy build --with github.com/caddy-dns/cloudflareFor containers, the official pattern is a multi-stage build:
FROM caddy:builder AS builder
RUN xcaddy build --with github.com/caddy-dns/cloudflare
FROM caddy:latest
COPY --from=builder /usr/bin/caddy /usr/bin/caddyIn practice, pin your versions in a real pipeline instead of using floating tags, so a rebuild next month doesn't surprise you.
Running it in Docker
services:
caddy:
image: caddy:2
restart: unless-stopped
ports:
- "80:80"
- "443:443"
- "443:443/udp"
volumes:
- ./Caddyfile:/etc/caddy/Caddyfile
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:Two details here save real pain. The 443/udp mapping is what lets HTTP/3 work. And /data must be a persistent volume: it holds your certificates and ACME account. If it vanishes on every redeploy, Caddy re-requests certificates each time and you'll hit CA rate limits at the worst possible moment.
Gotchas and honest trade-offs
Caddy isn't magic, and a post that only sells you on it isn't worth much. Here's what to watch for.
Directive order isn't file order. In a Caddyfile, directives are sorted into a fixed internal order, not executed top to bottom. That's usually what you want, but it can surprise you when you combine things like rewrite, redir and reverse_proxy. When you need explicit control, use handle blocks (mutually exclusive) or route (runs in the order written).
Third-party modules mean a custom build. The mainstream distribution is lean on purpose. Rate limiting, advanced caching and some DNS providers require building your own binary. That's easy with xcaddy, but it's a step nginx users don't always have to take.
The ecosystem is smaller. nginx has two decades of tutorials, blog posts and Stack Overflow answers for every odd corner case. Caddy's documentation is excellent and its community forum is responsive, but for exotic setups you'll sometimes be reading module docs rather than copy-pasting a snippet.
Network prerequisites still apply. Automatic HTTPS can't work around a firewall that blocks ports 80 and 443, DNS that points somewhere else, or a CA rate limit. When it fails, the logs are clear about why, so read them before assuming it's a bug.
Extreme tuning. For the overwhelming majority of sites, your proxy is not the bottleneck. If you operate at a scale where you're hand-tuning kernel parameters and worker models, you'll want to benchmark your own workload rather than trust anyone's blog post, including this one.
A production checklist
Point DNS first, then start Caddy, so the first certificate request succeeds.
Persist the data directory (
/datain Docker, or~/.local/share/caddyby default).Run
caddy validatein CI before every deploy.Use
caddy reload, not a restart, for config changes.Add
encode zstd gzipto anything serving text.Opt in to security headers (including HSTS) once you're certain every subdomain supports HTTPS.
Keep the admin API on localhost.
Send logs to stdout or a file and ship them somewhere searchable. Caddy emits structured JSON logs out of the box.
Run it under systemd or a container orchestrator, with a non-root user and permission to bind low ports.
When to choose Caddy
Choose Caddy when you want HTTPS handled for you, when you're running small to medium services, side projects, internal tools or container fleets, when you value configs that read like intent, or when you're managing many domains and want automation without a pile of glue scripts.
Think twice if you depend on a specific nginx module with no Caddy equivalent, if your team already has deep nginx expertise and tooling that works well, or if you need very particular behavior that only a mature, heavily documented ecosystem covers.
For everyone else, the question isn't "why Caddy?" so much as "why am I still maintaining certbot?"
The bottom line
Good infrastructure disappears. You stop thinking about it because it just works: certificates renew, configs reload, redirects redirect. Caddy's real contribution isn't any one feature. It's that it moved the default from "HTTPS is a project" to "HTTPS is already done."
Try it on something small this week. Replace one proxy, or put it in front of a side project with the three-line config at the top of this post. If the first thing you notice is that there's nothing left to configure, that's the point.
Comments(0)
Join the discussion by logging into your account.
No comments yet. Be the first to comment!