How to get a Let's Encrypt wildcard certificate with the DNS challenge

Get a free *.example.com certificate from Let's Encrypt with certbot or acme.sh and your DNS provider's API, renew it automatically and reload your web server.

Tested on Ubuntu 24.04 LTS, Debian 12 Oldstable

Before you start

  • A server running Ubuntu 24.04 or Debian 12
  • Root or a user with sudo
  • A domain whose DNS is hosted by a provider with an API (or that you can edit by hand)

To get a Let's Encrypt wildcard certificate, you have to use the DNS-01 challenge: your ACME client creates a TXT record at _acme-challenge.example.com through your DNS provider's API, Let's Encrypt checks it, and you get one certificate for example.com and *.example.com. With certbot that's one command with a DNS plugin (certbot certonly --dns-cloudflare -d example.com -d '*.example.com'), or with acme.sh acme.sh --issue --dns dns_cf ...; both then renew it by themselves.

Wildcards can't use the usual HTTP challenge, because Let's Encrypt can't fetch a file from every possible subdomain. The DNS challenge also helps when the server isn't reachable from the internet on port 80 (an internal tool behind a VPN, for example), since nothing has to connect to the server at all.

A wildcard covers exactly one level: *.example.com matches app.example.com and www.example.com, but not example.com itself (so ask for both names) and not api.eu.example.com.

Choose a client

certbot with a DNS plugin acme.sh
Install apt install, from your distribution A shell script, installed per user
DNS providers About a dozen packaged plugins, more on PyPI Over 150 providers built in
Renewal systemd timer from the package A cron job it adds itself
Default CA Let's Encrypt ZeroSSL (switch it to Let's Encrypt, below)

Use certbot if your DNS provider has a packaged plugin. Use acme.sh if it doesn't. You only need one of the two.

The examples use Cloudflare's DNS because it's common, not because you need it. The steps are the same for other providers; only the plugin name and the credentials change.

Create a DNS API token

In your DNS provider's dashboard, create an API token that can edit DNS records for this one domain and nothing else. On Cloudflare that's My Profile → API Tokens → Create Token, the "Edit zone DNS" template, with Zone Resources limited to example.com.

The token can change your DNS, so treat it like a password: it goes in a file only root can read, never in a repository or a shell history you share.

Option 1: certbot with a DNS plugin

Install certbot and the plugin for your DNS provider:

bash
sudo apt update
sudo apt install -y certbot python3-certbot-dns-cloudflare

Ubuntu 24.04 and Debian 12 package plugins for Cloudflare, DigitalOcean, DNSimple, Gandi, Google Cloud DNS, Linode, OVH, Route 53 and others: apt-cache search --names-only '^python3-certbot-dns' lists them. python3-certbot-dns-rfc2136 works with any DNS server that accepts dynamic updates, such as BIND or PowerDNS you run yourself.

Save the token in a file that only root can read:

bash
sudo mkdir -p /root/.secrets/certbot
sudo tee /root/.secrets/certbot/cloudflare.ini > /dev/null <<'EOF'
dns_cloudflare_api_token = YOUR_API_TOKEN
EOF
sudo chmod 600 /root/.secrets/certbot/cloudflare.ini

Ask for the certificate, for the bare domain and the wildcard together:

bash
sudo certbot certonly \
  --dns-cloudflare \
  --dns-cloudflare-credentials /root/.secrets/certbot/cloudflare.ini \
  -d example.com -d '*.example.com'

Quote '*.example.com' so the shell doesn't expand the *. Certbot asks for an email address the first time and for agreement to the terms. It creates the TXT records, waits 10 seconds for them to spread, lets Let's Encrypt check them and removes them again. If your provider is slow to publish changes, add --dns-cloudflare-propagation-seconds 60 (each plugin has its own --dns-<name>-propagation-seconds option).

Check it worked. Certbot ends with Successfully received certificate. and the paths:

bash
sudo certbot certificates

lists the certificate with Domains: example.com *.example.com and its expiry date. The files are in /etc/letsencrypt/live/example.com/.

Reload the web server on every renewal

The certbot package installs a systemd timer (certbot.timer) that runs certbot renew twice a day and renews certificates within 30 days of expiry. A renewed certificate only takes effect when the web server reloads, so add a deploy hook. It runs only after a successful renewal:

bash
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx > /dev/null <<'EOF'
#!/bin/sh
systemctl reload nginx
EOF
sudo chmod 755 /etc/letsencrypt/renewal-hooks/deploy/reload-nginx

Use systemctl reload caddy, systemctl reload apache2 or systemctl restart <your app> instead if that's what uses the certificate.

Check it worked. Run a test renewal against Let's Encrypt's staging server. It goes through the whole DNS challenge without replacing your certificate:

bash
sudo certbot renew --dry-run

It ends with Congratulations, all simulated renewals succeeded. systemctl list-timers certbot.timer shows when the next real check runs.

Option 2: acme.sh

Install acme.sh in root's home directory. It also adds a cron job that checks for renewals several times a day:

bash
sudo -i
curl https://get.acme.sh | sh -s email=admin@example.com
source ~/.bashrc

source ~/.bashrc loads the acme.sh alias the installer just added. Stay in this root shell for the rest of this section, and make Let's Encrypt the default CA first. acme.sh uses ZeroSSL otherwise:

bash
acme.sh --set-default-ca --server letsencrypt

Give it the token in the variable its Cloudflare module reads, and issue the certificate:

bash
export CF_Token="YOUR_API_TOKEN"
acme.sh --issue --dns dns_cf -d example.com -d '*.example.com'

acme.sh saves the token in /root/.acme.sh/account.conf for renewals, so you only export it once. Other providers use other module names and variables: the dnsapi list has every one, for example dns_ovh, dns_gandi_livedns, dns_aws, dns_nsupdate.

Don't point your web server at the files in ~/.acme.sh/: their layout can change. Copy them to a fixed place and tell acme.sh how to reload the server. It remembers this and repeats it after every renewal:

bash
mkdir -p /etc/ssl/example.com
acme.sh --install-cert -d example.com --ecc \
  --key-file       /etc/ssl/example.com/key.pem \
  --fullchain-file /etc/ssl/example.com/fullchain.pem \
  --reloadcmd      "systemctl reload nginx"

--ecc is there because acme.sh makes ECDSA (ec-256) keys by default.

Check it worked. acme.sh --list shows the certificate with both names and its renewal date, and crontab -l shows the acme.sh --cron line. To test the renewal path end to end, acme.sh --renew -d example.com --ecc --force renews now and runs the reload command. Use it sparingly: real renewals count towards Let's Encrypt's rate limits.

Use the certificate in nginx

Point any server block, for any subdomain, at the same files. With certbot:

nginx
server {
    listen 443 ssl;
    listen [::]:443 ssl;
    server_name app.example.com;

    ssl_certificate     /etc/letsencrypt/live/example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem;

    location / {
        proxy_pass http://127.0.0.1:3000;
    }
}

With acme.sh, use /etc/ssl/example.com/fullchain.pem and /etc/ssl/example.com/key.pem. Then sudo nginx -t && sudo systemctl reload nginx.

Check it worked.

bash
echo | openssl s_client -connect app.example.com:443 -servername app.example.com 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName -enddate

The output lists DNS:*.example.com, DNS:example.com and a notAfter date about 90 days away.

Caddy can also get wildcard certificates, but only with a build that includes your DNS provider's module (caddy add-package github.com/caddy-dns/cloudflare, for example). For most setups it's simpler to let Caddy fetch a certificate per name over HTTP, as in Caddy as a reverse proxy for several apps.

Without a DNS API

If your DNS provider has no API, certbot can ask you to add the TXT record by hand:

bash
sudo certbot certonly --manual --preferred-challenges dns -d example.com -d '*.example.com'

It prints the value to put at _acme-challenge.example.com and waits for you to press Enter. This works, but it can't renew by itself: you have to repeat it before the certificate expires every 90 days. Moving the domain's DNS to a provider with an API, or delegating only _acme-challenge to one with a CNAME, is the lasting fix.

Fix the common problems

Error determining zone_id: 9109 Invalid access token. Certbot reached Cloudflare but the token is wrong or was revoked. Check the file has dns_cloudflare_api_token = followed by the token, with no quotes, and that the token's zone resources include this domain.

Unable to determine zone_id for example.com using zone names. The token is valid but can't see this domain. Edit the token's permissions to include the zone.

No TXT record found at _acme-challenge.example.com or Incorrect TXT record. Let's Encrypt asked before the record had spread to all of your provider's name servers. Raise the propagation wait (--dns-cloudflare-propagation-seconds 60, or --dnssleep 120 for acme.sh). If the domain has an old, hand-made _acme-challenge record or CNAME, delete it.

CAA record for example.com prevents issuance. The domain has a CAA record that doesn't list Let's Encrypt. Add example.com. CAA 0 issue "letsencrypt.org", plus 0 issuewild "letsencrypt.org" if there is already an issuewild record.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee