The thing I wanted was simple to describe and slightly annoying to achieve: real TLS certificates for a dozen internal services, none of which I want exposed to the public internet, all of them reachable at tidy names like grafana.lan.example.com from inside the house without a browser warning every single time.
The naive approach is HTTP-01: let Let's Encrypt hit your server on port 80 to prove you own the domain. That requires opening a port to the world, which is exactly what I'm trying to avoid for services that should never leave the LAN. So the answer is DNS-01. Instead of proving control by serving a file, you prove it by writing a TXT record to your DNS. Your domain provider needs an API for this; mine does, and that's the only real prerequisite.
I run Caddy as the reverse proxy because it makes this almost embarrassingly easy. One wildcard certificate for *.lan.example.com, renewed automatically over DNS-01, and Caddy terminates TLS for everything behind it. The Caddyfile reads almost like the intent itself:
{
acme_dns cloudflare {env.CF_API_TOKEN}
}
grafana.lan.example.com {
reverse_proxy 10.4.2.20:3000
}
*.lan.example.com {
reverse_proxy {labels.3}.lan.example.com:8080
}
The acme_dns global directive is the whole trick. Caddy talks to the DNS provider, completes the DNS-01 challenge, gets the wildcard, and renews it on its own schedule. I pass the API token in via an environment variable rather than committing it, because a DNS API token is a credential that can rewrite your domain and deserves treating as such.
The one piece that lives outside Caddy is DNS itself. I point *.lan.example.com at the proxy's internal IP using my local resolver, so the names resolve to a 10.x address inside the house and never appear in public DNS at all. The public DNS only ever sees the TXT records during a challenge, which appear briefly and then get cleaned up. From the public internet, these services simply do not exist.
That's the bit I find satisfying. The certificates are real, signed by a CA every browser trusts, so there's no fiddling with a private CA and importing root certs onto every device, including the ones I don't control like a guest's phone or a smart TV. But the services themselves are completely private. I get the convenience of public PKI with none of the exposure.
A couple of things I'd flag if you're setting this up. Scope the DNS API token as tightly as the provider allows, ideally to just the one zone and just the records ACME needs to touch. And keep an eye on Let's Encrypt's rate limits while you're iterating; it's easy to burn through certificate issuances when you're debugging a misconfigured challenge, and the limits reset on a weekly window. Once it's working, though, it's the kind of infrastructure you forget exists, which is exactly what you want from infrastructure.