A wildcard certificate for *.example.com is valid for every first-level subdomain (app.example.com, api.example.com and so on), so new subdomains work over HTTPS without issuing a new certificate each time. Let's Encrypt issues wildcard certificates only through the DNS-01 challenge, where you prove control of the domain by publishing a TXT record. In this tutorial you will use Certbot and its Cloudflare DNS plugin on Ubuntu 24.04 to obtain a certificate for your_domain and *.your_domain, set up unattended renewal, and serve it from Nginx.
Prerequisites
To follow this guide you need:
- A server running Ubuntu 24.04 LTS, for example a CubePath VPS, and a non-root user with
sudoprivileges. - A registered domain whose DNS is hosted on Cloudflare. Throughout the guide, replace
your_domainwith it. Other DNS providers work with their own plugin; see Step 1. - DNS records pointing to your server: an
Arecord foryour_domainand anArecord for*(the wildcard), both with your server's public IPyour_server_ip.
A wildcard only covers one label: *.your_domain matches app.your_domain but not your_domain itself nor dev.app.your_domain. That is why this guide requests both your_domain and *.your_domain.
Step 1 - Installing Certbot and the DNS plugin
The DNS challenge requires Certbot to create a TXT record through your DNS provider's API, which is what DNS plugins do. Install Certbot, the Cloudflare plugin and Nginx (which will serve the certificate in Step 5) from the Ubuntu repositories:
sudo apt update
sudo apt install certbot python3-certbot-dns-cloudflare nginx
Allow HTTP and HTTPS through UFW:
sudo ufw allow 'Nginx Full'
Confirm the plugin is available:
sudo certbot plugins
* dns-cloudflare
Description: Obtain certificates using a DNS TXT record (if you are using
Cloudflare for DNS).
...
If your DNS is hosted elsewhere, Ubuntu 24.04 packages plugins for several other providers. The rest of the guide is the same; only the plugin flag and the credentials file change:
| DNS provider | Package | Certbot flag |
|---|---|---|
| Cloudflare | python3-certbot-dns-cloudflare | --dns-cloudflare |
| AWS Route 53 | python3-certbot-dns-route53 | --dns-route53 |
| DigitalOcean | python3-certbot-dns-digitalocean | --dns-digitalocean |
| OVHcloud | python3-certbot-dns-ovh | --dns-ovh |
| Your own BIND/PowerDNS (RFC 2136) | python3-certbot-dns-rfc2136 | --dns-rfc2136 |
Step 2 - Creating a Cloudflare API token
Give Certbot a token that can only edit DNS in one zone, not your whole Cloudflare account. In the Cloudflare dashboard:
- Open My Profile > API Tokens and click Create Token.
- Use the Edit zone DNS template.
- Under Zone Resources, select Include > Specific zone >
your_domain. - Create the token and copy it. Cloudflare shows it only once.
Store the token in a file that only root can read. Certbot runs as root, including during automatic renewal, so keep it under /etc/letsencrypt:
sudo nano /etc/letsencrypt/cloudflare.ini
dns_cloudflare_api_token = your_cloudflare_api_token
Restrict its permissions. Certbot warns if other users can read the file:
sudo chmod 600 /etc/letsencrypt/cloudflare.ini
sudo ls -l /etc/letsencrypt/cloudflare.ini
-rw------- 1 root root 55 Sep 25 10:12 /etc/letsencrypt/cloudflare.ini
Step 3 - Requesting the wildcard certificate
Run the request against the Let's Encrypt staging environment first with --dry-run. It goes through the full DNS challenge without issuing a real certificate, so mistakes do not count against the production rate limits:
sudo certbot certonly --dry-run \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
--dns-cloudflare-propagation-seconds 30 \
-d your_domain -d '*.your_domain'
The first time you run Certbot it asks for an email address for expiry notices and for acceptance of the terms of service. A successful dry run ends with:
The dry run was successful.
Now request the real certificate. The --deploy-hook is saved in the renewal configuration and reloads Nginx each time the certificate is renewed:
sudo certbot certonly \
--dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
--dns-cloudflare-propagation-seconds 30 \
-d your_domain -d '*.your_domain' \
--deploy-hook "systemctl reload nginx"
Quote '*.your_domain' so the shell does not try to expand the asterisk. Certbot creates a _acme-challenge.your_domain TXT record, waits 30 seconds for it to propagate, lets Let's Encrypt validate it, and then removes it:
Successfully received certificate.
Certificate is saved at: /etc/letsencrypt/live/your_domain/fullchain.pem
Key is saved at: /etc/letsencrypt/live/your_domain/privkey.pem
This certificate expires on ....
These files will be updated when the certificate renews.
Certbot has set up a scheduled task to automatically renew this certificate in the background.
List the certificates Certbot manages to confirm that both names are included:
sudo certbot certificates
Found the following certs:
Certificate Name: your_domain
Serial Number: 4a3f...
Key Type: ECDSA
Domains: your_domain *.your_domain
Expiry Date: ... (VALID: 89 days)
Certificate Path: /etc/letsencrypt/live/your_domain/fullchain.pem
Private Key Path: /etc/letsencrypt/live/your_domain/privkey.pem
Step 4 - Verifying automatic renewal
Let's Encrypt certificates are short-lived, so renewal must be automatic. The Ubuntu certbot package installs a systemd timer that runs certbot renew twice a day and renews any certificate close to expiry, reusing the plugin and credentials saved in /etc/letsencrypt/renewal/your_domain.conf. Check that the timer is active:
systemctl list-timers certbot.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Thu 2026-09-25 21:47:00 UTC 11h left - - certbot.timer certbot.service
Simulate a renewal to confirm the stored configuration works end to end:
sudo certbot renew --dry-run
Congratulations, all simulated renewals succeeded:
/etc/letsencrypt/live/your_domain/fullchain.pem (success)
If the dry run fails, fix it now: the most common cause is a token that was revoked or lacks DNS edit permission for the zone.
Step 5 - Using the certificate in Nginx
With one certificate covering every subdomain, a single Nginx server block can answer for all of them. Create a server block:
sudo nano /etc/nginx/sites-available/your_domain
server {
listen 80;
listen [::]:80;
server_name your_domain *.your_domain;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
listen [::]:443 ssl http2;
server_name your_domain *.your_domain;
ssl_certificate /etc/letsencrypt/live/your_domain/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your_domain/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
root /var/www/html;
index index.html index.nginx-debian.html;
location / {
try_files $uri $uri/ =404;
}
}
The redirect uses $host rather than $server_name, so a request for app.your_domain is redirected to the same subdomain and not to the first name in the list. Always point ssl_certificate at fullchain.pem, which contains your certificate plus the intermediate; cert.pem alone causes chain errors on many clients.
Enable the site, test the configuration and reload Nginx:
sudo ln -s /etc/nginx/sites-available/your_domain /etc/nginx/sites-enabled/
sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
Test a subdomain that you never configured explicitly. Thanks to the wildcard DNS record and the wildcard certificate, it works over HTTPS:
curl -sI https://anything.your_domain | head -n 1
HTTP/2 200
Check which names the served certificate covers:
echo | openssl s_client -connect your_server_ip:443 -servername anything.your_domain 2>/dev/null | openssl x509 -noout -subject -ext subjectAltName
subject=CN=your_domain
X509v3 Subject Alternative Name:
DNS:*.your_domain, DNS:your_domain
In a real deployment you would usually add separate server blocks for specific subdomains (for example api.your_domain proxying to an application) that reuse the same two ssl_certificate lines.
Troubleshooting
Unable to determine zone identifier for your_domain. The token does not have access to that zone. Recreate it with Zone Resources set to the correct zone.Incorrect TXT record ... found at _acme-challenge.your_domain. Validation ran before the record propagated. Increase--dns-cloudflare-propagation-seconds(for example to60). You can watch the record during a run withdig +short TXT _acme-challenge.your_domain @1.1.1.1.- Renewal succeeds but browsers still see the old certificate. Nginx was not reloaded. Check that
renew_hook = systemctl reload nginxappears in/etc/letsencrypt/renewal/your_domain.conf, or add a script to/etc/letsencrypt/renewal-hooks/deploy/. too many certificates already issued. You hit a Let's Encrypt rate limit by repeating real requests. Wait for the limit window to pass and use--dry-runfor testing.- Your DNS provider has no plugin.
certbot certonly --manual --preferred-challenges dnslets you create the TXT record by hand, but such certificates cannot renew unattended. Moving DNS to a provider with an API is the better fix.
Conclusion
You obtained a Let's Encrypt certificate for your_domain and *.your_domain using the DNS-01 challenge, verified that Certbot renews it automatically and reloads Nginx, and served every subdomain over HTTPS with a single certificate. Next, you can add server blocks that proxy individual subdomains to applications, enable HSTS once all subdomains are HTTPS-only, and monitor expiry dates with your usual alerting as a safety net.
