# 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.

Source: https://vpsnine.com/tutorials/letsencrypt-wildcard-certificate-dns-challenge · Updated: 2026-10-21

Difficulty: intermediate · Time: about 25 minutes · Tested on: Ubuntu 24.04 LTS, Debian 12 Oldstable

Prerequisites:

- 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](https://github.com/acmesh-official/acme.sh/wiki/dnsapi) 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](https://vpsnine.com/tutorials/caddy-reverse-proxy-multiple-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

- [Set up nginx with free Let's Encrypt HTTPS](https://vpsnine.com/help/nginx-and-https) covers the simpler HTTP challenge for single names.
- [Host a static website with nginx](https://vpsnine.com/tutorials/host-static-website-nginx), with HTTP/2 and caching headers.
- [Caddy as a reverse proxy for several apps](https://vpsnine.com/tutorials/caddy-reverse-proxy-multiple-apps).
- Let's Encrypt's [challenge types](https://letsencrypt.org/docs/challenge-types/) and [rate limits](https://letsencrypt.org/docs/rate-limits/) explain the rules behind all of this.
