Running a local web app over plain HTTP is fine until something forces your hand — a third-party SDK that refuses to load on http://, a cookie that needs the Secure flag, or an app like Moodle that nags you on every page about being insecure. At that point you want a real-looking HTTPS URL pointing at localhost, with a certificate your browser doesn’t yell about. 🔐
The combo I keep coming back to: mkcert for the cert, Caddy as the reverse proxy, and a small /etc/hosts entry to make the hostname resolve. Total setup is maybe five minutes. Here’s the shape of it.
Step 1 — make a trusted cert with mkcert
mkcert installs a local Certificate Authority (CA) into your operating system’s trust store the first time you run it. After that, every cert it issues is automatically trusted by browsers on that machine. No self-signed warning popups.
1 2 3 4 5 6 7 8 9 | # One-time setup brew install mkcert nss # nss = trust for Firefox mkcert -install # Issue a cert for your local hostname cd ~/dev/myapp mkcert myapp.example.test # → myapp.example.test.pem (cert) # → myapp.example.test-key.pem (private key) |
Pick a hostname that won’t collide with a real one. The .test top-level domain is reserved for exactly this purpose by RFC 2606, so it’s a safer choice than inventing your own. I sometimes use my company’s domain for muscle memory — that works too, but be aware you’re squatting on a real Domain Name System (DNS) name locally.
Step 2 — make the hostname resolve to your machine
Your browser asks DNS where myapp.example.test lives. DNS has no idea — so you tell your machine directly, by editing /etc/hosts:
1 | sudo nano /etc/hosts |
Add a line:
1 | 127.0.0.1 myapp.example.test |
That’s macOS and Linux. On Windows, the same file lives at C:\Windows\System32\drivers\etc\hosts and you’ll need to open your editor as Administrator to save it. (I haven’t run a Windows dev box in a while — if it’s different on a current version, the principle is the same: there’s a hosts file, it maps names to IPs, your changes win over real DNS.)
Verify it took effect:
1 | ping myapp.example.test # should resolve to 127.0.0.1 |
Step 3 — wire up Caddy as a reverse proxy
Caddy’s config is one of the friendlier ones to write. Each site is a block; tls takes the cert pair; reverse_proxy hands the request off to your app. Your Caddyfile ends up looking like this:
1 2 3 4 5 6 7 8 9 10 | { auto_https disable_redirects } myapp.example.test { tls /Users/me/dev/myapp/myapp.example.test.pem /Users/me/dev/myapp/myapp.example.test-key.pem reverse_proxy localhost:8080 { header_up X-Forwarded-Proto https } } |
The auto_https disable_redirects in the global block is important — without it, Caddy will try to do its own HTTP-to-HTTPS redirect, which is helpful in production but interferes with local dev when your app is also issuing redirects.
Reload Caddy:
1 2 3 | brew services restart caddy # or, if running in foreground: caddy reload --config /opt/homebrew/etc/Caddyfile |
Hit https://myapp.example.test. You should land on your app, served on real-looking HTTPS, with a green padlock. 🎉
The gotcha: ERR_TOO_MANY_REDIRECTS
This one bites everyone the first time. You update your app’s configured base URL from http://localhost:8080 to https://myapp.example.test, reload the page, and the browser shows:
1 2 3 | This page isn't working myapp.example.test redirected you too many times. ERR_TOO_MANY_REDIRECTS |
Here’s what’s happening. Caddy terminates Transport Layer Security (TLS) on its side, then forwards a plain http:// request to your app on port 8080. Your app looks at the incoming request, sees it arrived as HTTP, but its configured base URL is HTTPS — so it issues a 302 back to the HTTPS URL. The browser follows it. Caddy receives it, strips TLS again, forwards HTTP. Loop forever. 🔁
Two fixes, both needed:
- Caddy tells the app it was HTTPS originally. The header_up X-Forwarded-Proto https line in the reverse_proxy block (shown above) does this.
- The app trusts that header. Frameworks usually have a flag for this. In Moodle it’s $CFG->sslproxy = true; in config.php. In Laravel it’s the TrustProxies middleware. In Express it’s app.set(‘trust proxy’, 1). Without this, the app keeps thinking it’s an HTTP request and the redirect loop continues.
Both halves matter. Forwarding the header without trusting it is silent; trusting a header that isn’t being sent does nothing. 💡
The whole flow, in one mental model
- mkcert gives you a trusted cert for a chosen hostname.
- /etc/hosts points that hostname at your machine.
- Caddy listens on 443 with the cert, terminates TLS, forwards the request to your app’s plain-HTTP port.
- X-Forwarded-Proto + the app’s “trust proxy” flag tells the app the original request was HTTPS, so it stops redirecting in circles.
Once the four pieces are in place, every new project that needs HTTPS is a two-minute job: one mkcert call, one new block in the Caddyfile, one hosts entry. Worth setting up once. 🛠️