I used to dread setting up https for local development. Self-signed certs got the browser to scream. Editing nginx.conf for two hostnames felt like building a cathedral. Caddy changed all that for me — it’s a tiny single-binary web server that does automatic HTTPS out of the box. Point it at a hostname, and it either gets a real Let’s Encrypt cert (for public domains) or generates and trusts a local cert (for development) — without you running certbot, openssl req, or anything else.
This post is the cheat sheet I wish I’d had: install it, point it at a local app, get https in five minutes. ⏱️
Install
Caddy is a single static binary. The package managers wrap it nicely:
1 2 3 4 5 6 7 8 | # macOS brew install caddy # Debian / Ubuntu sudo apt install -y debian-keyring debian-archive-keyring apt-transport-https curl curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/gpg.key' | sudo gpg --dearmor -o /usr/share/keyrings/caddy-stable-archive-keyring.gpg curl -1sLf 'https://dl.cloudsmith.io/public/caddy/stable/debian.deb.txt' | sudo tee /etc/apt/sources.list.d/caddy-stable.list sudo apt update && sudo apt install caddy |
The Debian install also drops a caddy systemd service and a default Caddyfile at /etc/caddy/Caddyfile. On macOS, Homebrew puts the example Caddyfile under /opt/homebrew/etc/Caddyfile (Apple Silicon) or /usr/local/etc/Caddyfile (Intel).
The simplest possible Caddyfile
Caddy reads a config file called the Caddyfile — a tiny domain-specific format that maps hostnames to behaviours. The smallest useful one:
1 2 3 | myapp.local { reverse_proxy localhost:8000 } |
Three lines. “When something asks for myapp.local, terminate TLS and forward the plaintext request to localhost:8000.” Caddy generates a local certificate, installs the matching root CA into your system trust store the first time it runs (you’ll be prompted for a password), and serves https://myapp.local with a green padlock — provided myapp.local resolves to your machine. Add a line to /etc/hosts:
1 | 127.0.0.1 myapp.local |
Run it:
1 2 3 4 5 6 7 8 | # Foreground (good for trying it out) caddy run --config /opt/homebrew/etc/Caddyfile # As a background service (Debian / systemd) sudo systemctl enable --now caddy # Reload after editing the Caddyfile (no downtime) sudo systemctl reload caddy |
Bring your own cert
Sometimes you don’t want Caddy’s auto-generated cert — maybe you’ve already created one with mkcert, or you’ve been issued a cert by your team’s internal CA. Tell Caddy where the .pem files live with the tls directive:
1 2 3 4 | myapp.example.com { tls /path/to/myapp.example.com.pem /path/to/myapp.example.com-key.pem reverse_proxy localhost:8000 } |
The first argument is the certificate (full chain), the second is the private key. Caddy stops trying to auto-issue and just uses what you gave it.
Generating a development cert with mkcert is the path of least resistance — install it once, run mkcert -install (which adds its CA to your system trust store), then for any hostname:
1 2 | mkcert myapp.example.com # Creates myapp.example.com.pem and myapp.example.com-key.pem in the current directory |
Multiple sites in one block
If you have several hostnames that should share the same TLS settings and proxy target — common with multi-tenant local development — put them on one line, comma-separated:
1 2 3 4 5 6 7 | myapp.local, one.myapp.local, two.myapp.local { tls /path/to/myapp.local+2.pem /path/to/myapp.local+2-key.pem reverse_proxy localhost:80 { header_up Host {host} header_up X-Forwarded-Proto https } } |
Two things worth noticing in that block:
- header_up Host {host} forwards the original Host header to the upstream — important when your app routes by hostname (multi-tenant, virtual hosts, etc.). Without this, the upstream sees localhost and may not know which tenant is being requested.
- header_up X-Forwarded-Proto https tells the upstream that the original connection was https. Frameworks like Laravel, Django, and Rails need this to generate correct absolute URLs and to enforce secure-cookie flags.
The +2 in the cert filename is an mkcert convention: when you generate a cert for multiple hostnames, mkcert names the file after the first one and appends +N for the count of additional SANs (Subject Alternative Names).
Useful global options
The block at the very top of the Caddyfile, wrapped in plain { … } with no hostname, is the global options block. The two I reach for most:
1 2 3 | { auto_https disable_redirects } |
By default, Caddy auto-redirects http:// traffic to https://. Useful in production, occasionally annoying locally — for example, if you’re testing a service that’s already running on port 80 with its own non-https endpoint, the redirect gets in the way. disable_redirects turns that off but keeps the auto-cert magic. Other handy globals:
1 2 3 4 5 | { debug # verbose logs while iterating email you@example.com # used by Let's Encrypt for cert expiry warnings storage file_system /var/lib/caddy # where issued certs are cached } |
The thing that won me over
Once you’ve used Caddy for a week, going back to nginx + certbot for a new project feels strange. The Caddyfile fits on a Post-it. There’s no separate cron job to renew certs — Caddy renews them itself. There’s no special config for HTTP/2 or HTTP/3 — they’re on by default. And when the site doesn’t load, the error message tells you why in one sentence, not via a stack trace from journalctl.
It’s not a replacement for nginx everywhere — at high traffic, behind a CDN, or as a proxy for very specialised workloads, nginx still has the edge. But for personal sites, internal tools, and local development, Caddy is hard to beat. 🎉