How to Get a Let's Encrypt Wildcard Certificate with the DNS Challenge (HTTPS for Internal Services, No Open Ports)

The normal Certbot flow (tutorial 27) proves you own a name by answering on port 80 from the internet. That's no use for nas.home.example.com, which resolves only inside your house. The DNS challenge proves ownership differently: Certbot adds a TXT record to your domain's DNS through the provider's API, Let's Encrypt reads it, and issues the certificate — including wildcards. No port forward, no public reachability, real certificates trusted by every device without installing anything. It's the cleanest answer to internal HTTPS if you own a domain.
Quick reference
| Task | What |
|---|---|
| Needs | a domain whose DNS has an API: Cloudflare (free), DuckDNS, deSEC, Route53, many others |
| Plugin | certbot-dns-cloudflare (or -duckdns, -desec, …) |
| Token | Cloudflare → API Tokens → Edit zone DNS for that zone |
| Issue | certbot certonly --dns-cloudflare -d "*.home.example.com" -d home.example.com |
| Files | /etc/letsencrypt/live/home.example.com/ |
| Renew | automatic via Certbot's timer — nothing to open |
1. Install the plugin
sudo apt install certbot python3-certbot-dns-cloudflare -y
For DuckDNS: pipx install certbot-dns-duckdns (and run that certbot). For other providers, apt search certbot-dns or the certbot-dns-* pip packages — Lego and acme.sh support even more if yours isn't there.
2. The API token
Cloudflare → My Profile → API Tokens → Create Token → Edit zone DNS template, zone resources limited to example.com. Copy it.
sudo mkdir -p /etc/letsencrypt/secrets
sudo nano /etc/letsencrypt/secrets/cloudflare.ini
dns_cloudflare_api_token = YOUR_TOKEN
sudo chmod 600 /etc/letsencrypt/secrets/cloudflare.ini
3. Issue the wildcard
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/secrets/cloudflare.ini \
--dns-cloudflare-propagation-seconds 30 \
-d "*.home.example.com" -d home.example.com
Certbot creates _acme-challenge.home.example.com TXT records, waits, Let's Encrypt checks them, the records are removed, and the certificate lands in /etc/letsencrypt/live/home.example.com/ (fullchain.pem and privkey.pem). Renewal is already scheduled by the Certbot timer and uses the same plugin — no port 80 ever involved.
4. Internal DNS
Make the names resolve inside the house: Pi-hole Local DNS → DNS Records (34) with *.home.example.com isn't possible directly, so add each name (nas.home.example.com → 192.168.1.50), or use Pi-hole's dnsmasq wildcard: a file in /etc/dnsmasq.d/ (mounted into the container) containing address=/home.example.com/192.168.1.50. Publicly, those names need not exist at all.
5. Use the certificate
Nginx — one ssl_certificate pair for every internal server block:
server {
listen 443 ssl;
server_name nas.home.example.com;
ssl_certificate /etc/letsencrypt/live/home.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/home.example.com/privkey.pem;
location / { proxy_pass http://127.0.0.1:9443; ... }
}
Add a --deploy-hook "systemctl reload nginx" to the Certbot command (or /etc/letsencrypt/renewal-hooks/deploy/reload.sh) so renewals take effect.
Caddy does the DNS challenge itself with a provider module (xcaddy build --with github.com/caddy-dns/cloudflare, then tls { dns cloudflare {env.CF_TOKEN} } in a global block) — no Certbot needed.
Nginx Proxy Manager (58): SSL Certificates → Add → Use a DNS Challenge with the same token; pick the wildcard on each host.
Other machines (the Pi running something with its own HTTPS): copy the two files over with a deploy hook and scp, or run Certbot there with the same token.
6. Keep the token safe
It can edit your whole zone's DNS. chmod 600, never in a compose file or git, and scope it to one zone. If it leaks, revoke it in Cloudflare and make another.
Troubleshooting
"DNS problem: NXDOMAIN looking up TXT". Propagation; raise --dns-cloudflare-propagation-seconds to 60. Or the token lacks edit rights on the zone.
Certificate issued but the browser still warns. It's serving another certificate — nginx -t, the right ssl_certificate path, and the name in the URL must match the wildcard's level (*.home.example.com covers nas.home.example.com, not nas.example.com or a.b.home.example.com).
Renewal fails after months. Token revoked or expired; sudo certbot renew --dry-run shows the error.
No domain. Then it's mkcert or step-ca (tutorial 146), or a free subdomain with API access (DuckDNS, deSEC's dedyn.io) — the plugin exists for both.
Wrapping up
A DNS plugin, a scoped API token in a 600 file, one certbot certonly for *.home.example.com. A real certificate for every internal service with no open port and no root to install, renewing by itself — and Nginx, Caddy, NPM and the Pi all share it.
