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.

Let's EncryptcertbotnginxDockerDevOpsIncident response

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

Keep reading