Ramblings of an aging IT geek
← Ramblings of an aging IT geek
homelab

real certificates for things that never touch the internet

Putting Caddy in front of homelab services and getting valid Let's Encrypt certificates for internal-only hostnames using the DNS-01 challenge, so nothing has to be exposed to the public internet.

A small server rack with patch cables

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.

A homelab reverse proxy configuration on screen

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.