jamell.dev

Caddy can front multiple local k3d clusters on one port, with free automatic HTTPS

2026-08-19 (4w ago)6 views

#caddy#k3d#kubernetes

I wanted hello.local.test to hit a hello-world app running on a local k3d cluster. First instinct was to map the cluster's ingress straight to host port 80 with k3d cluster create local -p 80:80@loadbalancer. That works fine — until you want a second local cluster later (say cluster-a) also reachable over plain http://something.cluster-a.test. Only one process can bind host port 80, so the first cluster to grab it locks everyone else out. Not great if you're the kind of person who spins up throwaway clusters for different experiments.

The fix: don't let any single cluster own port 80. Give each cluster its own unique, non-privileged host port instead, and put a single always-on reverse proxy in front of all of them, routing by domain.

The setup

Create the cluster with an arbitrary host port instead of 80:

k3d cluster create local -p "9080:80@loadbalancer"

Then run Caddy as a brew service (brew install caddy, it ships with a launchd agent) and give it a Caddyfile that maps each cluster's domain suffix to its own port:

# local (k3d-local)
*.local.test {
	tls internal
	reverse_proxy localhost:9080
}

Reload with brew services restart caddy. Now every *.local.test request lands on Caddy on port 80/443, and Caddy forwards it to whichever cluster's k3d loadbalancer is listening on 9080. Adding a second cluster later is just: pick another free port (9081, 9082, ...), add another block to the Caddyfile, restart Caddy. No cluster ever touches port 80 directly.

One thing that tripped me up: Caddy passes the original Host header through to the upstream by default (unlike nginx, which needs proxy_set_header Host $host explicitly). That matters here because the k8s Ingress resource inside the cluster routes by Host header — if Caddy rewrote it, the in-cluster ingress controller (Traefik, in k3s's case) wouldn't know which app to route to.

Free automatic HTTPS, even for a fake TLD

Caddy is famous for automatic HTTPS via Let's Encrypt, but that requires a real, publicly resolvable domain since ACME has to validate it over the internet. .test isn't a real TLD (it's literally reserved by IANA for testing and will never resolve publicly), so ACME can't work here.

Turns out Caddy has a second, separate mechanism for exactly this situation: an internal CA. If you write the site block without an explicit scheme:

*.local.test {
	tls internal
	reverse_proxy localhost:9080
}

...instead of forcing http://*.local.test { ... }, Caddy issues itself a local self-signed cert from its own internal CA and serves HTTPS on 443, auto-redirecting from 80. The one-time step is trusting that CA so your browser doesn't complain:

caddy trust

This installs Caddy's root cert into the system keychain (needs sudo once). After that, https://hello.local.test just works — valid cert, HTTP/2, everything — for a domain that will never exist on the public internet. I didn't expect a static reverse proxy config to get me real TLS for free on a fake domain, but it's a genuinely nice side effect of Caddy defaulting to "always try to be secure" instead of "plaintext unless configured otherwise."

The key gotcha: if you write http:// explicitly in the site address, that pins the block to plaintext-only and disables automatic HTTPS entirely for it. Drop the scheme to opt back into tls internal.

Docs: https://caddyserver.com/docs/automatic-https and https://caddyserver.com/docs/caddyfile/directives/tls#internal