Skip to content
PAVEL GLUKHIKH
Menu

Engineering Note

Wildcard certificates with Let's Encrypt DNS-01

Issuing Let's Encrypt wildcard certificates via DNS-01 with acme.sh and certbot: API-token scoping, propagation pitfalls, and renewal monitoring that works.

2 min read

TL;DR

Let's Encrypt issues wildcard certificates only through the DNS-01 challenge: you prove control of the zone by publishing a TXT record at _acme-challenge.<domain> via your DNS provider's API. This note records the acme.sh and certbot commands that survive unattended renewal, the four things that quietly break it — over-scoped API tokens, propagation lag, CAA records, split-horizon zones — and the external expiry probe that catches silent failures before users do.

The working commands

DNS-01 is the only path to a wildcard, and it needs API access to your zone. With acme.sh and Cloudflare (a scoped token, never the global key):

export CF_Token="<token with Zone.DNS:Edit on this zone only>"

acme.sh --issue --server letsencrypt --dns dns_cf \
  -d example.com -d '*.example.com'

acme.sh --install-cert -d example.com \
  --key-file       /etc/ssl/private/example.com.key \
  --fullchain-file /etc/ssl/certs/example.com.pem \
  --reloadcmd      "systemctl reload nginx"

--install-cert matters: it registers the copy-and-reload step so renewals actually land in the web server, not just in ~/.acme.sh/.

The certbot equivalent:

# /root/.secrets/cloudflare.ini  (chmod 600)
# dns_cloudflare_api_token = <token>

certbot certonly --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \
  --dns-cloudflare-propagation-seconds 60 \
  -d example.com -d '*.example.com' \
  --deploy-hook "systemctl reload nginx"

Both clients handle the TXT record lifecycle: create _acme-challenge.example.com, wait, validate, delete.

Pitfalls that break unattended renewal

Token scope. The credential sits on a server forever, so scope it to DNS-edit on the single zone. A global API key on an edge box converts “web server compromised” into “entire DNS estate compromised” — the kind of finding that tops a security architecture review.

Propagation lag. Validation servers query your authoritative DNS. If the API updates a primary that transfers to secondaries slowly, validation races the zone transfer. Raise --dns-cloudflare-propagation-seconds (certbot) or --dnssleep 120 (acme.sh, which otherwise polls public resolvers) until it’s boring.

CAA records. A CAA record that doesn’t include letsencrypt.org fails issuance with a clear error humans read and cron jobs don’t. Check before automating: dig CAA example.com +short.

Split-horizon DNS. If the internal view of the zone differs from the public one, make sure the API is editing the zone the internet sees. I’ve watched renewals fail for weeks because the TXT record landed only in an internal view — one more reason DNS architecture decisions deserve documentation.

No API at your provider? Delegate just the challenge: _acme-challenge.example.com CNAME <random>.acme-dns.yourlab.net, pointing into a throwaway zone (acme-dns or a small BIND) whose credentials can’t touch production DNS. This also keeps powerful tokens off exposed hosts.

Renewal monitoring: watch the symptom

Both clients install timers (systemctl list-timers | grep -E 'certbot|acme'), and in practice both fail silently — expired tokens, changed zone IDs, provider API changes. The timer running is not the certificate renewing. Monitor the certificate actually being served, from outside:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -enddate

In Prometheus terms: a blackbox_exporter TLS probe with an alert on probe_ssl_earliest_cert_expiry - time() < 21 * 86400. Twenty-one days is the right threshold for Let’s Encrypt: renewal starts at 60 days of the 90-day lifetime, so anything under 21 days remaining means at least a week of failed renewal attempts.

Dry-run the whole chain once after setup, and again after any DNS provider change:

certbot renew --dry-run          # certbot
acme.sh --renew -d example.com --force   # acme.sh, against staging first if unsure

The renewal that fails is never the one you tested. Treat the cert pipeline like any other piece of infrastructure as code: version the config, monitor the output, and assume the automation is broken until a probe proves otherwise.

Frequently asked questions

Why can't I get a wildcard certificate with HTTP-01?
Let's Encrypt policy: wildcard names are only issued via DNS-01. HTTP-01 proves control of one hostname by serving a file; a wildcard asserts control of every possible subdomain, and publishing a DNS TXT record in the zone is the only challenge that demonstrates that level of control.
Do I need both the apex and the wildcard on the certificate?
Usually yes. *.example.com does not cover example.com itself, so request both names. Each name gets its own DNS-01 challenge, which means two TXT records at _acme-challenge.example.com simultaneously — valid DNS, but a few provider APIs mishandle multiple TXT values at one name.
How do I monitor certificate renewal properly?
Monitor the symptom, not the mechanism: probe the live endpoint's certificate expiry from outside (blackbox_exporter, ssl_exporter, or a cron with openssl) and alert below 21 days remaining. Let's Encrypt certs live 90 days and renew at 60, so under 21 days means renewal has been silently failing for over a week.

References

Related reading