For years my homelab ran on a soup of self-signed certificates and the browser warnings that come with them. Every service had its own untrusted cert, every first visit was a click-through, and every device I added had to be taught, individually, to stop complaining. It worked, in the sense that a thing held together with tape works, and I'd quietly accepted it as the cost of running stuff at home. It is not the cost. You can have proper, browser-trusted certificates for services that are never exposed to the internet at all, and once I set it up I was annoyed I'd waited so long.
The catch with the obvious approach is that the standard Let's Encrypt challenge, HTTP-01, needs a publicly reachable server on port 80. That's the whole point: it proves you control the domain by serving a token from it. For a homelab service bound to a 192.168 address, that's a non-starter, and I'm not about to punch a hole in the firewall just to prove I own a hostname I only use indoors.
The answer is the DNS-01 challenge. Instead of serving a token over HTTP, you prove control of the domain by creating a TXT record in its DNS. That works for any hostname under a domain you control, regardless of whether the host it points at is reachable from outside. My internal services live under a subdomain of a domain I own, the DNS for which is hosted at a provider with an API, and that combination is all DNS-01 needs.
I run Caddy as the reverse proxy because it makes this almost insultingly simple. Caddy obtains and renews certificates automatically, and with the right DNS plugin it'll do the DNS-01 dance on its own. The config is short enough to read in one go:
{
acme_dns cloudflare {env.CF_API_TOKEN}
}
grafana.lab.example.com {
reverse_proxy 10.0.0.20:3000
}
nas.lab.example.com {
reverse_proxy 10.0.0.21:5000
}
That's it. The global acme_dns block tells Caddy to use the DNS-01 challenge via Cloudflare, authenticating with a scoped API token that can only touch DNS records for the relevant zone. Each site block is a hostname and where to proxy it. On first request Caddy creates the TXT record, waits for Let's Encrypt to verify it, gets a real certificate, removes the record, and serves the service over HTTPS with a cert every browser already trusts. Renewals happen quietly in the background and I never think about them.
A couple of things worth knowing before you copy this. The API token should be scoped as tightly as the provider allows: DNS edit rights on the one zone, nothing else, because that token can rewrite your DNS and it's sitting in a config file on a box in your house. And the internal hostnames need to resolve, which means either your DNS provider returns the private IP for those names, or you run a bit of internal DNS that does. I do the latter, with a local resolver that answers *.lab.example.com with the right RFC1918 addresses, so the names work indoors and never leak a single internal IP to the public DNS.
The result is a homelab where every service has a green padlock, every device trusts it out of the box, and nothing is exposed to the internet to make that happen. No more click-through warnings, no more per-device cert wrangling, no more tape. Just hostnames that work like real ones, because as far as the certificate is concerned, they are.