Field notes
The site went down because Let's Encrypt renewal had been failing for three months. Standalone vs. webroot behind nginx in Docker.
The message said the cloud provider had disabled the platform again. SSH worked fine, every container was up, and the browsers said "certificate has expired". Three months of failed renewals, two certificate stores, and a fix that took an hour once the cause was clear.
First reflex: is it really the provider?
When a site is "down", test SSH before anything else. If SSH answers, it is not a suspended account or an unpaid bill. Here it answered immediately — uptime 46 days, every container Up. Then:
$ echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -dates
notBefore=May 6 17:43:12 2026 GMT
notAfter=Aug 4 17:43:11 2026 GMT # yesterday
Expired. Every browser refuses the connection; to a non-technical person that is indistinguishable from "the server is off".
Why renewal had been failing since May
The timer was doing its job:
$ systemctl list-timers certbot.timer
NEXT LEFT LAST PASSED UNIT
Wed 2026-08-05 21:14:00 UTC 3h left Wed 2026-08-05 09:02:11 UTC 8h ago certbot.timer
Twice a day, for three months. And every run failed:
$ sudo certbot renew --dry-run
Failed to renew certificate example.com with error:
Could not bind TCP port 80 because it is already in use by another process.
The renewal configuration told the whole story:
# /etc/letsencrypt/renewal/example.com.conf
[renewalparams]
authenticator = standalone
The certificate had been issued with the standalone authenticator — certbot starts its own web server on port 80 to answer the ACME challenge. That works exactly once: while nginx is stopped, on the day you first set things up. From then on, nginx owns port 80, certbot cannot bind it, and renewal fails silently twice a day. No alert, because nothing was watching the timer's result.
The second trap: two certificate stores
Renewing was not enough. nginx ran in a container, and its /etc/letsencrypt was a Docker volume — a copy taken at setup time — not the host's /etc/letsencrypt that certbot writes to.
$ docker inspect nginx --format '{{range .Mounts}}{{.Name}} -> {{.Destination}}{{"\n"}}{{end}}'
app_certbot_conf -> /etc/letsencrypt
app_certbot_www -> /var/www/certbot
So a successful renewal on the host would have changed nothing in the browser. Two stores, one updated by certbot, the other served by nginx, and nothing between them.
The fix, with zero downtime
1. Switch to webroot. nginx already had the standard challenge location, pointing at the certbot_www volume:
location /.well-known/acme-challenge/ { root /var/www/certbot; }
So the challenge can be answered through nginx, with nginx running:
sudo certbot certonly -n --webroot \
-w /var/lib/docker/volumes/app_certbot_www/_data \
--cert-name example.com -d example.com -d www.example.com -d app.example.com
This both issues a fresh certificate and rewrites the renewal config to authenticator = webroot, which is the real fix: future renewals no longer need port 80.
2. Sync the store nginx reads. Copy archive/ and live/ (both — live/ is symlinks into archive/) into the volume, then reload nginx gracefully:
sudo rsync -a /etc/letsencrypt/archive/example.com/ /var/lib/docker/volumes/app_certbot_conf/_data/archive/example.com/
sudo rsync -a /etc/letsencrypt/live/example.com/ /var/lib/docker/volumes/app_certbot_conf/_data/live/example.com/
sudo docker kill -s HUP nginx # same as nginx -s reload, no dropped connections
3. Make step 2 automatic. A deploy hook runs after every successful renewal:
# /etc/letsencrypt/renewal-hooks/deploy/sync-nginx-certs.sh (chmod +x)
#!/bin/sh
set -e
V=/var/lib/docker/volumes/app_certbot_conf/_data
rsync -a /etc/letsencrypt/archive/example.com/ "$V/archive/example.com/"
rsync -a /etc/letsencrypt/live/example.com/ "$V/live/example.com/"
docker kill -s HUP nginx
4. Prove it end to end.
sudo certbot renew --dry-run # must say "Congratulations, all simulated renewals succeeded"
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -enddate # the NEW date, served by nginx, from outside
The dry run exercises the webroot path and the hook. If it passes, the timer will renew about 30 days before expiry and nginx will pick it up.
What I would have needed to avoid the outage
- Monitor the outcome, not the schedule.
certbot.timerbeing active means nothing. Alert oncertbot renewexit status, or simply check the served certificate's expiry from outside every day and alert under 20 days. A five-line cron does it, and it belongs next to the other checks in a monitoring bot. - One certificate store. Mount the host's
/etc/letsencryptread-only into the nginx container instead of copying it into a volume. Then there is nothing to sync. - Never issue with
standaloneon a machine that runs a web server. If you have to (first setup, nginx not yet configured), switch the authenticator to webroot the same day. - Write the diagnosis down for the client. "The provider disabled the server" was not the first time this symptom got that explanation. A one-line reflex — does SSH work? — separates infrastructure incidents from application incidents in ten seconds.