It started, like most of my regrets, with a bookmark folder.
app-one:3000. dashboard:8501. that-thing-i-forgot:8080. A dozen self-hosted apps, each squatting on its own port, each throwing a browser security warning because I’d long since stopped pretending to manage individual certs. Half were self-signed. Half were plain HTTP. All of them were ugly.
Every new app was the same ritual: pick a port, pray it’s free, wire up TLS by hand, give up on TLS, add another :PORT bookmark, lose it. The homelab worked. It just looked like a ransom note.
So I sat down to do it properly. Once.
The investigation
The core problem wasn’t the apps. It was that I had N apps and N TLS problems. Every service believed it was personally responsible for the open internet. None of them were good at it.
Here’s the mess, drawn honestly:
BEFORE AFTER
┌──────────────────┐ ┌──────────────────────────┐
│ browser │ │ browser │
└───┬───┬───┬──────┘ └──────────┬───────────────┘
│ │ │ (which port?) │ one name, valid TLS
▼ ▼ ▼ ▼
:3000 :8501 :8080 ┌─────────┐
self-signed / http / ??? │ Caddy │ *.lab.example.tld
each app owns its own cert └──┬──┬──┬─┘ wildcard cert
│ │ │
▼ ▼ ▼
127.0.0.1:3000/8501/8080
(apps bound to localhost)
The fix wasn’t to make every app better at certificates. It was to make none of them do certificates. Put one thing in front. Let it own TLS, routing, and renewal. Bind everything else to 127.0.0.1 so the only thing facing the world is the proxy.
That “one thing” is a reverse proxy. Mine’s Caddy.
The “aha”
The trick that makes it sing is a wildcard certificate — *.lab.example.tld, one cert that’s valid for every subdomain I’ll ever invent — obtained over a DNS-01 challenge instead of the usual HTTP-01.
DNS-01 matters here. HTTP-01 proves you own a name by serving a file on it, which means each name needs to be reachable. DNS-01 proves it by dropping a TXT record at your DNS provider, which means you can mint a wildcard without exposing a single app first. One challenge, infinite names.
Once that clicked, the whole bookmark folder collapsed into one file.
The fix
Caddy needs a DNS-provider plugin to answer the DNS-01 challenge, so build it with the module for your provider:
xcaddy build \
--with github.com/caddy-dns/cloudflare
# verify the module is in there
./caddy list-modules | grep dns.providers
Then the global block tells Caddy how to get the wildcard, and each app is — genuinely — three lines:
# Caddyfile
{
email admin@example.tld
# credentials for the DNS-01 challenge
acme_dns cloudflare {env.CF_API_TOKEN}
}
# the wildcard, requested once, renewed forever
*.lab.example.tld {
tls {
dns cloudflare {env.CF_API_TOKEN}
}
}
app-one.lab.example.tld {
reverse_proxy 127.0.0.1:3000
}
dashboard.lab.example.tld {
reverse_proxy 127.0.0.1:8501
}
Reload without dropping connections:
caddy validate --config /etc/caddy/Caddyfile
caddy reload --config /etc/caddy/Caddyfile
Confirm a fresh subdomain actually got real HTTPS:
curl -sI https://dashboard.lab.example.tld | head -n1
# HTTP/2 200
echo | openssl s_client -connect dashboard.lab.example.tld:443 2>/dev/null \
| openssl x509 -noout -subject
# subject=CN = *.lab.example.tld
Adding the next app is now: pick a port, add a three-line block, caddy reload. Valid cert, no thought required.
Why it happened
The old setup wasn’t dumb, it was just additive. Each app got stood up in isolation, made its own peace with HTTPS, and got a port-shaped bookmark. Nobody ever sat down to factor out the part every app shares: a public face and a certificate.
A reverse proxy is exactly that factoring. TLS, hostname routing, and renewal are cross-cutting concerns — solve them once, in front, and every app inherits the solution for free. The wildcard cert is what makes “inherits for free” literally true: no per-app issuance, no per-app renewal, no per-app anything.
Takeaways
- One TLS problem beats N TLS problems. Put a reverse proxy in front and let it own certs, routing, and renewal so your apps never have to.
- Wildcard + DNS-01 is the unlock.
*.domainminted via a DNS TXT challenge covers every subdomain you’ll ever invent — and you don’t have to expose an app to prove ownership. - Bind apps to
127.0.0.1. If only the proxy faces the world, a misconfigured app can’t accidentally serve itself raw to the internet. - New app = three lines. A subdomain block that
reverse_proxys to a localhost port, a reload, done. Exposing a service stops being a project. - Build the right Caddy. DNS-01 needs the provider plugin compiled in —
xcaddy build --with, thenlist-modulesto confirm before you wonder why the challenge hangs.