acme.sh: Wildcard Certificate with Cloudflare DNS
Internal services behind a reverse proxy often run with self-signed certificates or none at all, because the usual HTTP challenge needs the host to be reachable from the internet on port 80. This article uses acme.sh with the Cloudflare DNS API to get a trusted Let's Encrypt wildcard certificate for *.internal.example.com. The host never has to be publicly reachable, and the certificate renews on its own.
How It Works
The DNS-01 challenge proves that you control a domain by asking you to create a _acme-challenge TXT record. acme.sh creates that record through the Cloudflare API, waits until Let's Encrypt has validated it, then deletes it again. Wildcard certificates are only available via DNS-01, so this is the only way to get one.
A wildcard also keeps your internal hostnames private. Every publicly trusted certificate is published in Certificate Transparency logs, which anyone can search. A certificate for *.internal.example.com reveals only that subdomain, while separate certificates for nas.internal.example.com and proxmox.internal.example.com would expose your whole inventory.
The DNS zone has to be public and hosted at Cloudflare. The A records for the internal hosts can live on your local DNS server (AdGuard, Pi-hole, router) instead.
Pre-requisites
- A domain whose DNS is managed by Cloudflare (the Free plan is enough)
- A Linux host running nginx, with root access
curl,cronandsocatavailable. The installer warns if something is missing.- Outbound HTTPS to
api.cloudflare.comandacme-v02.api.letsencrypt.org
All commands below run as root (sudo -i). acme.sh installs into the home directory of the user who runs it, and it needs root to write the key files and reload nginx.
Create a Cloudflare API Token
acme.sh needs permission to edit DNS records in one zone. Don't use the Global API Key, because it gives full control over your whole Cloudflare account. Create a scoped token instead:
- In the Cloudflare dashboard, go to My Profile → API Tokens → Create Token.
- Use the Edit zone DNS template. It grants
Zone → DNS → Edit. - Under Zone Resources, select Include → Specific zone → example.com.
- Optional: under Client IP Address Filtering, restrict the token to your server's public IP.
- Click Continue to summary → Create Token, and copy the token. Cloudflare shows it only once.
Next, open the zone's Overview page and copy the Zone ID from the API section in the right-hand sidebar.
Check that the token works before you hand it to acme.sh:
curl -s https://api.cloudflare.com/client/v4/user/tokens/verify -H "Authorization: Bearer <your-token>"
{"result":{"id":"…","status":"active"},"success":true,"errors":[],"messages":[{"code":10000,"message":"This API token is valid and active","type":null}]}
Install acme.sh
The official installer downloads acme.sh to ~/.acme.sh/, adds a shell alias and creates a cron job. The email address is used to register your ACME account. Let's Encrypt stopped sending expiry warning emails in June 2025, so don't count on that address for alerts.
acme.sh uses ZeroSSL as its CA unless you choose another one. Switch the default to Let's Encrypt:
Issue the Wildcard Certificate
Provide the API Credentials
acme.sh reads the Cloudflare credentials from environment variables. read -rs prompts for the token without echoing it and keeps it out of your shell history:
After the first successful run, acme.sh saves both values to ~/.acme.sh/account.conf and reuses them for every renewal. You don't need to export them again.
Request the Certificate
A wildcard doesn't cover the bare name, so request both internal.example.com and *.internal.example.com. Quote the wildcard so the shell doesn't expand it. --keylength ec-384 creates an ECDSA P-384 key. Without that flag acme.sh uses P-256, which is also fine.
A successful run ends like this:
[Sat Oct 18 10:42:11 CEST 2026] Cert success.
[Sat Oct 18 10:42:11 CEST 2026] Your cert is in: /root/.acme.sh/internal.example.com_ecc/internal.example.com.cer
[Sat Oct 18 10:42:11 CEST 2026] Full-chain cert is in: /root/.acme.sh/internal.example.com_ecc/fullchain.cer
Do not point nginx at ~/.acme.sh/
The files in ~/.acme.sh/ are for acme.sh's internal use only, and their layout can change between versions. Always copy them to their final location with --install-cert (next step).
Install the Certificate for nginx
--install-cert copies the key and the full chain to a directory of your choice. It also saves those paths and the reload command, so that every future renewal copies the new files and reloads nginx automatically. Use a neutral directory such as /etc/ssl/acme/ rather than /etc/letsencrypt/, which belongs to certbot.
mkdir -p /etc/ssl/acme/internal.example.com
chmod 700 /etc/ssl/acme
acme.sh --install-cert -d internal.example.com --ecc --key-file /etc/ssl/acme/internal.example.com/key.pem --fullchain-file /etc/ssl/acme/internal.example.com/fullchain.pem --reloadcmd "systemctl reload nginx"
--ecc tells acme.sh to use the ECC certificate (the _ecc directory) and not an RSA one with the same name.
Reference the files in each nginx server block that should use the certificate:
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name nas.internal.example.com;
ssl_certificate /etc/ssl/acme/internal.example.com/fullchain.pem;
ssl_certificate_key /etc/ssl/acme/internal.example.com/key.pem;
location / {
proxy_pass http://192.168.1.20:5000;
}
}
Test the configuration and reload nginx:
Automatic Renewal
The installer added a cron job for root. Since acme.sh 3.1.4 it runs every 6 hours and follows ARI (ACME Renewal Information), which means the CA tells acme.sh when to renew. You don't need to change anything when Let's Encrypt shortens its certificate lifetimes. The default drops from 90 to 64 days on 10 February 2027 and to 45 days in February 2028.
Confirm that the cron job exists:
The minute value differs on each host. Keep acme.sh itself up to date as well:
Verify
List the managed certificates and check that the key type and renewal date look right:
Main_Domain KeyLength SAN_Domains CA Created Renew
internal.example.com "ec-384" *.internal.example.com LetsEncrypt 2026-10-18T08:42:11Z 2026-12-17T08:42:11Z
Then check what nginx actually serves. The SAN list must contain both names, and the issuer must be Let's Encrypt:
openssl s_client -connect nas.internal.example.com:443 -servername nas.internal.example.com </dev/null 2>/dev/null | openssl x509 -noout -issuer -ext subjectAltName -enddate
issuer=C = US, O = Let's Encrypt, CN = E8
X509v3 Subject Alternative Name:
DNS:*.internal.example.com, DNS:internal.example.com
notAfter=Jan 16 08:42:10 2027 GMT
To test the complete renewal path, including the copy step and the nginx reload, force one renewal. Let's Encrypt rate-limits identical certificates to 5 per week, so don't run this in a loop:
The output must end with Reload success.
Common Issues
| Symptom | Cause | Fix |
|---|---|---|
invalid domain or Error add txt for domain |
Token not scoped to this zone, wrong CF_Zone_ID, or token expired |
Check the token with the verify call, then compare the Zone ID with the zone's Overview page |
Authentication error (code 10000) |
Token blocked by Client IP Address Filtering | Add the server's current public IP to the token's filter |
zsh: no matches found: *.internal… |
The shell tried to expand the unquoted wildcard | Quote it: -d '*.internal.example.com' |
| Certificate issued by ZeroSSL instead of Let's Encrypt | Default CA not changed before issuing | Run --set-default-ca --server letsencrypt, then issue again |
| Renewal works but nginx keeps serving the old certificate | --install-cert was never run, so no reload command is stored |
Run the --install-cert command from above once |
--renew says Skipping. Next renewal time is … |
Not due yet according to ARI or the renewal date | Expected behaviour. Add --force only for a one-off test |
too many certificates already issued |
Duplicate-certificate rate limit hit | Wait for the window to pass. For experiments use --server letsencrypt_test |