A wildcard certificate on one server is easy. You switch to the DNS-01 challenge, certbot renews it every couple of months, and you forget it exists. The trouble starts when the same *.example.com has to live on a second box, then a NAS, then a cloud VM in front of everything. Renewal works on one machine and quietly fails on the others, and you find out when a browser shows a red warning.
We run a handful of servers behind one domain, including a mail server, and went through this. This piece covers why per-host renewal breaks, the single-issuer pattern that keeps working, and the certbot hook detail that decides whether your script runs at the right moment.
Why each server renewing on its own goes wrong
Nothing in Let's Encrypt stops several hosts from requesting the same wildcard. The problems are around it.
Rate limits. Let's Encrypt allows up to 5 certificates per exact same set of identifiers every 7 days, and the limit is global: orders from every account count. Three servers, each reinstalled or tested a couple of times, can use that up in an afternoon.
Challenge delegation. This is the one behind "only the last server renews". With acme-dns, each registration returns its own random subdomain, and you point _acme-challenge.example.com at it with a CNAME. A name can have only one CNAME. When the third server registers and you update the record, the first two keep writing TXT values to subdomains the CA no longer looks at. Their renewals fail with a DNS validation error.
DNS credentials everywhere. Every host that renews needs a token that can write to your zone. Three hosts means three copies of a key that can change any record you own.
HTTP-01 does not scale either. Behind a home router or one public IP, port 80 can forward to only one machine. On one of our networks inbound port 80 was filtered upstream, and HTTP-01 renewals failed for weeks before anyone noticed. DNS-01 does not depend on any inbound port, which is why it is the right base for this.
The pattern: one issuer, then push
Pick one machine to own the certificate. It holds the DNS API token, runs certbot or acme.sh, and is the only host that talks to the CA. Every other server just receives files.
- One renewal schedule to watch instead of three.
- One DNS token, on one machine.
- Rate limits stop being a concern, because one host renews one certificate.
- Adding a fifth server is one more line in a script, not another ACME setup.
The receiving servers need a fixed path for the files, a user that can write there, and permission to reload their own web or mail server. Nothing else.
Use the deploy hook, not the post hook
Certbot has three hook types, and the certbot documentation is specific about when each one runs:
| Hook | When certbot runs it | Good for |
|---|---|---|
--pre-hook | Before each renewal attempt, only when a cert is due | Stopping a service that holds port 80 |
--post-hook | After each renewal attempt, including failed ones | Starting that service again |
--deploy-hook | Only after a certificate was actually renewed | Copying the new files and reloading servers |
Hooks only fire when a certificate is due for renewal, so the cron or systemd timer can run certbot renew often without side effects. The difference is in what happens when renewal fails. A post hook runs anyway. If your copy script lives there, a failed renewal still pushes the old files out and reloads every server, and your logs say "deployed" while the certificate gets closer to expiry. A deploy hook runs only after a successful renewal.
The deploy hook also gets two variables: $RENEWED_LINEAGE, the live directory with the new files (for example /etc/letsencrypt/live/example.com), and $RENEWED_DOMAINS, the names on the renewed certificate. Any executable file in /etc/letsencrypt/renewal-hooks/deploy/ runs as a deploy hook, so you do not have to remember the flag.
A minimal version:
#!/bin/sh
# /etc/letsencrypt/renewal-hooks/deploy/push-certs.sh
set -e
for host in web2 nas vm-proxy; do
rsync -aL "$RENEWED_LINEAGE/" "certsync@$host:/etc/ssl/wildcard/"
ssh "certsync@$host" 'sudo nginx -t && sudo systemctl reload nginx'
done
Three details matter. rsync -L copies the real files, because the live/ directory holds symlinks into archive/. The certsync user on each target should have an SSH key limited to that job, and sudo rights only for the nginx test and reload. And nginx -t runs before the reload, so a broken config on one host does not take the site down.
Mail servers need the same treatment. Postfix, Dovecot and most others load the certificate at start, so they keep serving the old one until reloaded. If your mail server also uses the wildcard, add its reload command to the loop. We covered the mail stack side in Mailcow vs Stalwart vs Mailu in production.
Test it without waiting two months
Run certbot renew --dry-run -v on the issuer first. It talks to the staging server and shows the real challenge error if there is one. The full log is in /var/log/letsencrypt/letsencrypt.log. One catch: according to the certbot docs, deploy hooks do not run during a dry run unless you add --run-deploy-hooks. Without it you have tested the renewal but not the distribution.
Then check what each server actually serves, not what is on its disk:
echo | openssl s_client -connect web2.example.com:443 -servername web2.example.com 2>/dev/null | openssl x509 -noout -enddate
If the dates differ between hosts, the copy or the reload failed on that one. Put that check in your monitoring with an alert at 14 days left. It costs nothing and catches the failures the hook cannot see. If the hosts run in containers, send the hook output to stdout so it ends up with the rest of your logs, as described in why container logs belong on stdout.
Shorter certificates make this mandatory
Let's Encrypt has announced the timeline for shorter lifetimes, in line with the CA/Browser Forum Baseline Requirements. On 10 February 2027 the default profile moves from 90-day to 64-day certificates. On 16 February 2028 it moves to 45 days, and the time a domain validation can be reused drops from 30 days to 7 hours. The opt-in tlsserver profile was scheduled to issue 45-day certificates from 13 May 2026.
A setup where someone copies a renewed certificate to the other servers by hand works, with some luck, at 90 days. At 45 days it fails. A single issuer with a deploy hook keeps working no matter how short the lifetime gets.
Checklist
- One host requests and renews the wildcard. The DNS token exists only there.
- If you use acme-dns, exactly one registration and one CNAME per name.
- The copy and reload script lives in
renewal-hooks/deploy/, not in a post hook. - Restricted SSH key per target, sudo only for test and reload.
nginx -t(or the mail server's check) before every reload.- Dry run with
--run-deploy-hooksonce after setup. - External expiry check on every hostname, alert at 14 days.
Frequently asked questions
Can every server request its own copy of the same wildcard certificate?
Yes, but each copy counts against Let's Encrypt's limit of 5 certificates per exact set of names every 7 days, across all accounts. With three or four servers and a few test runs you can hit it. One issuer avoids the problem.
Why does renewal work only on the server I set up last?
The usual cause is challenge delegation. With acme-dns, each registration gives you a new subdomain, and the _acme-challenge CNAME can point to only one of them. The servers registered earlier write their TXT records to a name the CA never checks.
Does certbot renew --dry-run test my deploy hook?
No. The certbot documentation says deploy hooks do not run during a dry run unless you add --run-deploy-hooks. Pre and post hooks do run.
Do I need to change anything for 45-day certificates?
If renewal and distribution are fully automated, no. If any step is manual, yes: Let's Encrypt's default profile moves to 64-day certificates on 10 February 2027 and to 45 days on 16 February 2028, so a manual copy every two months stops being enough.