Your certificate renewed. Your visitors still got the old one.

Renewal automation fails silently more often than it fails loudly: the cron exits zero, the logs say success, and the server keeps serving the old certificate. Why it happens and how to catch it.

Cover Image for Your certificate renewed. Your visitors still got the old one.

There is a category of SSL failure that produces no errors anywhere. The renewal cron ran on schedule. Certbot wrote a fresh certificate to disk and exited zero. The log line says "Congratulations!". And your web server is still handing every visitor the old certificate, counting down to expiry.

This failure is invisible to every check that trusts the renewal process to report on itself. Sysadmins describe the same pain in the same words: silent failures, and no logs of renewals that tell you anything useful. Both complaints are really one complaint. The renewal pipeline only knows what happened on disk. Your visitors only receive what the server process has loaded into memory. Those are two different certificates the moment a reload is missed.

How the gap opens

A TLS server reads its certificate once, at startup or on an explicit reload. Writing a new file to /etc/letsencrypt/live/ changes nothing about what is being served until the process is told to re-read it. The renewal and the reload are separate steps, and the second one is the one that gets lost. The usual ways:

The deploy hook was never configured. Certbot only reloads your server if you told it to, with --deploy-hook "systemctl reload nginx" or a script in /etc/letsencrypt/renewal-hooks/deploy/. A setup that was tested by running certbot --nginx interactively once often has no hook at all, because the interactive run did the reload itself and the timer-driven renewals that follow do not.

The hook exists but reloads the wrong thing. The certificate moved from nginx to haproxy, or a second service started using the same certificate, and the hook still only reloads the original one. Mail servers are the classic victim: postfix and dovecot read the same Let's Encrypt directory, appear on nobody's checklist, and serve the stale certificate for weeks after the web server was fixed.

The reload ran and failed quietly. haproxy needs the certificate and key concatenated into one PEM; if the concatenation step breaks, the reload keeps the old bundle. nginx reload validates config first; if an unrelated config error crept in since the last reload, the reload is refused and the old workers keep serving. In both cases the renewal log still says success, because the renewal did succeed.

Only some servers reloaded. Behind a load balancer, five backends renew and four reload. Visitors see a valid certificate or a warning depending on which backend they land on. This one produces the most confusing bug reports of all, because both you and the customer are right about what you are seeing. The most tangled version of this isn't a reload gap at all — it's one domain serving three different certificates while stale DNS routes each visitor to a different host, so the failure changes on every refresh.

Why your monitoring probably misses it

Checks that watch the renewal side all pass in this scenario. The cron exit code is zero. The certificate file's expiry date, read with openssl x509 -in cert.pem, is a year away. A log scraper finds the success line.

The only signal that reflects reality is the certificate presented on a live TLS connection to the public endpoint, from outside the machine. That is what a browser negotiates, and it is the only place the disk-versus-memory gap is visible:

echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
  | openssl x509 -noout -enddate -issuer

If that date says next month and the file on disk says next year, you have found the gap while it is still a curiosity instead of an outage. Run the same check against every port that speaks TLS, not just 443. SMTP on 465 or 587, IMAPS on 993, and an admin panel on 8443 each hold their own copy of the certificate in their own process.

Closing the gap for good

Three steps, in order of value:

  1. Add the deploy hook, per service. For certbot: --deploy-hook that reloads every process using the certificate, not just nginx. List them by checking what actually has the file open: lsof /etc/letsencrypt/live/example.com/fullchain.pem on a quiet moment after a restart is revealing.

  2. Test the whole pipeline once, deliberately. certbot renew --dry-run tests issuance but not your reload. Force a real renewal ahead of schedule with --force-renewal (once, not in a loop; the rate limits are real), then run the openssl check above and confirm the served notBefore date changed. Ten minutes, and you have proven the one path that matters.

  3. Monitor from the outside, permanently. An external check that performs a real handshake against the public hostname and alerts on days-to-expiry catches every variant of this failure, including the ones you have not thought of, because it measures the thing users experience instead of the process that is supposed to produce it. This is what CertPost does, checking the served certificate and the full chain on every port you give it, with a free tier that covers three certificates. But whether you use a service or a cron job you wrote yourself, the principle is the point: trust what the server presents, not what the renewal reports.

The teams that get burned by this are not careless. They automated renewal precisely because they were careful, and the automation worked well enough for long enough that nobody watched it anymore. The fix is not more discipline. It is moving the check to the only place that cannot lie.

One check now, or every day from now on.

3 certificates free forever · No agent · No credit card