Local HTTPS in 5 minutes with Caddy 🔒

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. 🎉

This entry was posted in Web Development and tagged , , . Bookmark the permalink.

Comments are closed.