Every homelab has that one service where everyone has learned to click "Accept the risk and continue". Trained click-through is exactly the habit an attacker loves. The good news: your internal services can get real certificates without opening a single port.
The short answer
How do internal homelab services get a trusted certificate without a port open to the internet? You use a domain you own and let an ACME client prove control of it with the DNS-01 challenge: the client writes a TXT record at _acme-challenge, Let's Encrypt checks it in public DNS, and then it issues the certificate [1][2]. With DNS-01 you can get a wildcard such as *.home.example.com, serve it from your reverse proxy and resolve the names to private addresses inside your network only. The price: you need a DNS provider with an API, the API token becomes a valuable secret, and every name in a certificate ends up in public logs. All facts are as of 25 Oct 2026. In this guide, "homelab" means the servers and services you run at home for yourself.
How it works
- Why
.localand.internaldo not work. Public certificate authorities only issue for names in the public DNS that you control. Caddy's documentation listslocalhost,.local,.internaland.home.arpaamong the names that do not qualify for publicly trusted certificates [11]. So you need a real domain. A subdomain likehome.example.comis enough, and it keeps homelab names apart from your website. - The challenge, step by step. The ACME protocol is standardised in RFC 8555 [2]. Your client creates an account key and orders a certificate. For each name, the CA hands out a token. The client combines the token with the fingerprint of its account key (the "key authorization"), hashes it with SHA-256, encodes it in base64url and publishes the result as a TXT record at
_acme-challenge.<name>[2]. Let's Encrypt queries DNS for that record. If the value matches, the name counts as validated [1]. Nothing has to reach your network, because only the DNS provider's servers answer. - Why DNS-01 and not HTTP-01. HTTP-01 needs port 80 reachable from the internet, and neither HTTP-01 nor TLS-ALPN-01 can issue wildcard certificates [1]. DNS-01 can do both: wildcards, and names whose servers are not exposed to the public internet [1]. It cannot validate IP addresses, though [1].
- What a wildcard covers. A wildcard may only stand as the complete left-most label, and it matches exactly one label [3]. So
*.home.example.comcoversnas.home.example.com, but neitherhome.example.comitself nora.b.home.example.com[3]. If you want the bare name too, put both in the order, as the examples of acme.sh and lego do with-d example.com -d '*.example.com'[16][18]. Both names then need their own TXT value under the same_acme-challengename. Several TXT records may exist there, but clean up old ones, because Let's Encrypt rejects responses that grow too big [1]. - Keep the API token small. Let's Encrypt warns that full DNS API credentials on a web server raise the damage if that server is hacked. It recommends narrowly scoped credentials, or running the validation on a separate machine and copying the certificates over [1]. In practice: create a token that may only edit DNS records, only in this one zone, and add an IP filter if your provider offers one. The acme.sh documentation for Cloudflare, as one example, names exactly that minimum: "Zone > DNS > Edit" for the specific zone, and it discourages the global API key [18]. Keep the token out of configuration files that end up in Git. Caddy reads it from an environment variable, lego from an env file, and acme.sh stores it in its
account.conf, so protect that file [12][16][18]. - Delegate the challenge if your provider is weak. Let's Encrypt follows CNAME and NS records when it looks up the TXT record. You can therefore point
_acme-challenge.home.example.comto a separate validation zone at a provider with a good API [1]. Your main zone then needs no API token at all. Caddy hasdns_challenge_override_domainfor this; Traefik calls CNAME support "supported and even encouraged" [12][15]. - Plan for propagation. DNS providers need time until a new record is visible on all their servers. Without an API that reports this, Let's Encrypt suggests waiting long enough, often as much as an hour [1]. Clients check for the record before they ask for validation. Caddy waits up to 2 minutes by default (
propagation_timeout), Traefik haspropagation.delayBeforeChecks, lego has--dns.propagation.wait[12][15][17]. One homelab trap: if your internal resolver answers for your own zone, the propagation check may see the internal answer instead of the public one. Point the check to public resolvers with Caddy'sresolversor Traefik'sdnsChallenge.resolvers[12][15]. That is our reading of these options, not a statement from the projects. - Pick a client. Caddy needs a build that includes the module for your DNS provider from the
caddy-dnsrepositories. You download it from the Caddy site or build it withxcaddy[12][14]. Traefik uses lego internally and lists its DNS providers with the environment variables each one needs [15]. acme.sh is a shell script with its own DNS API scripts, lego a single Go binary [16][18]. Our assessment: if you already run one of these proxies, use its built-in ACME support. A separate client pays off when other devices also need the certificate. - Resolve the names locally. The public zone needs only the
_acme-challengerecords. Your internal resolver (router, Pi-hole, AdGuard Home or Unbound) answers*.home.example.comwith the private address of your reverse proxy. This keeps internal addresses out of public DNS. Our assessment: this split is the cleaner choice. If you put private addresses in public DNS instead, DNS rebinding protection gets in the way. Unbound removes private addresses from answers for public names when you setprivate-address, and lets you allow single domains withprivate-domain. Records you configure locally withlocal-dataare allowed by default [19]. OPNsense offers the same as "Private Domains" and "Rebind Protection networks" [20]. A FRITZ!Box suppresses such answers too. You add exceptions as full host names under Home Network > Network > Network Settings, in the "DNS Rebind Protection" section; depending on the FRITZ!OS version, it sits behind the advanced settings [21][22]. - Lifetimes and renewal. Let's Encrypt's default certificates are valid for 90 days. Short-lived certificates of about 6 days are optional, and the
tlsserverprofile has issued 45-day certificates since 13 May 2026 [5][6][7]. On 10 Feb 2027 the default drops to 64 days, on 16 Feb 2028 to 45 days. The period in which a successful validation can be reused shrinks from 30 days to 10 days and then to 7 hours [7][8]. Let's Encrypt recommends ACME Renewal Information (ARI), which tells the client when to renew. A fixed renewal after 60 days will no longer be enough [7]. lego uses ARI unless you switch it off [17]. Traefik assumes 2,160 hours (90 days) withcertificatesDurationto plan renewals [15]. Our reading: if you choose a shorter profile there, adjust that value. Let's Encrypt stopped its expiry emails on 4 Jun 2025 [8], so monitor expiry dates yourself. - Rate limits and staging. As of 25 Oct 2026, Let's Encrypt allows 50 certificates per registered domain and 5 per identical set of names every 7 days, plus 5 failed validations per name and account per hour [4]. Renewals via ARI are exempt from all rate limits. A renewal without ARI (same set of names) is exempt from the per-domain limit, but still counts towards the 5 per set and the failed validations [4][9]. Test with the staging environment, which has much higher limits [4]. Caddy (
ca), Traefik (caServer) and lego (--server) let you switch the endpoint [12][15][17]. - CAA as a lock on the door. A CAA record names the CAs that may issue for your domain.
issue "letsencrypt.org"allows Let's Encrypt; the parametervalidationmethods=dns-01restricts it to DNS validation, andaccounturirestricts it to your ACME account [10]. The CA looks for the CAA record closest to the name and follows CNAMEs [10]. - Names become public. Issued certificates appear in public Certificate Transparency logs, and Let's Encrypt points to search tools such as crt.sh [4]. A certificate for
nas.home.example.comtells the world that the name exists. Our reading: a wildcard keeps the individual service names out of the certificate. - The alternative: your own CA. Without a domain, or in a network that must stay offline, Caddy can run its own local CA. It needs no ACME and no DNS, but every device has to trust its root [11]. Our reverse proxy guide compares both options in more detail.
A worked example (example.com and the addresses are placeholders)
The setup: a reverse proxy on a private address, a domain example.com, homelab names under home.example.com. No port is forwarded.
| Where | Record | Purpose |
|---|---|---|
| Public DNS | example.com CAA 0 issue "letsencrypt.org;validationmethods=dns-01" | Only Let's Encrypt, only via DNS-01 [10] |
| Public DNS | _acme-challenge.home.example.com TXT ... | Set and removed by the ACME client |
| Internal resolver | *.home.example.com → 192.168.1.10 | Names point to the proxy, invisible from outside |
The Caddyfile follows the wildcard pattern from the Caddy documentation. Cloudflare is the example provider in Caddy's own docs, not our recommendation, and every provider module has its own syntax [12][13]:
*.home.example.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
resolvers 1.1.1.1
}
@nas host nas.home.example.com
handle @nas {
reverse_proxy 192.168.1.20:5000
}
handle {
abort
}
}abort closes connections for names you have not configured [13]. The token sits in an environment variable, not in the file. For a first run, set the staging endpoint and switch to production once it works [12]. With lego, the same order with the token in a separate env file looks like this, again with the provider from the lego docs. Since lego v5, all flags follow run [16][17]:
lego run --server https://acme-staging-v02.api.letsencrypt.org/directory --dns cloudflare --domains 'home.example.com' --domains '*.home.example.com' --env-file .env.dnsWe did not run these configurations against a live provider. They follow the documentation, and options can change between versions.
Checklist
- Choose a subdomain of a domain you own for all homelab names.
- Check that your DNS provider has an API and is supported by your client, or delegate
_acme-challengeby CNAME. - Create a token that may only edit DNS records in this zone, and store it outside Git.
- Test with the Let's Encrypt staging environment before you switch to production.
- Request the wildcard and, if needed, the bare name in the same order.
- Set local DNS records for the names, and allow the domain in your rebinding protection only if you publish private addresses.
- Add a CAA record with
letsencrypt.organdvalidationmethods=dns-01. - Turn on ARI or a renewal schedule that copes with 45-day certificates, and monitor expiry dates.
- Remember that every name in a certificate is public, and keep internal details out of names.
Related pages: Reverse proxy for a homelab; DNS; Zero trust; DNS ad blocking: Pi-hole or AdGuard Home; DDNS; WireGuard or Tailscale for homelab access; Uptime Kuma.