Certbot exited 0. The log says success. fullchain.pem on disk has a notAfter 89 days out.
openssl s_client against :443 still prints last month's date.
That gap is the whole problem. Disk and wire disagree. Visitors get the wire.
Run Step 0 once. Compare the two dates. Then walk the five modes below until they match:
- Service never reloaded after renew
- ACME challenge stopped validating
- Timer or cron is gone
- Errors went somewhere nobody reads
- Rate limit ate the real renew
Each mode has one command that settles it.
Step 0 — check what your server is actually serving
Not what's on disk. Not what the certbot log says. What a visitor's browser receives right now:
echo | openssl s_client -connect yourdomain.com:443 -servername yourdomain.com 2>/dev/null \ | openssl x509 -noout -dates -issuer
Or run our free SSL check — same answer plus the chain, hostname coverage and TLS config, no signup.
If the notAfter date is comfortably in the future, your renewal is fine and something else scared you. If it's near or past, one of the five failures below is your problem.
1. Renewal succeeded — the service never reloaded
Certbot wrote a fresh certificate to disk, exited zero, logged success. But nginx, Apache, HAProxy or Postfix loads certificates at startup, and nothing told it to reload. It keeps serving the old cert until it expires, while every log on the box says you renewed weeks ago.
Compare disk to wire:
# what's on disk openssl x509 -noout -enddate -in /etc/letsencrypt/live/yourdomain.com/fullchain.pem # what's being served (from step 0)
If they disagree, that's it. Reload the service, then make it permanent with a deploy hook so this never depends on someone remembering:
# /etc/letsencrypt/renewal-hooks/deploy/reload.sh (chmod +x) #!/bin/sh systemctl reload nginx
We wrote up a full postmortem of this exact failure — renewed on disk, old cert on the wire, nobody the wiser.
Full write-up with the reload diagnosis and deploy-hook commands: nginx reloaded nothing. Certbot still exited 0.
2. The challenge stopped validating
Renewal worked for months, then silently didn't. Something changed around it:
- HTTP-01: port 80 got firewalled, or a new redirect rule sends
/.well-known/acme-challenge/somewhere else. Common after "we forced HTTPS everywhere" tickets. - DNS-01: someone rotated the DNS provider's API token, migrated the zone, or tightened IAM permissions. Certbot can no longer write the TXT record. One team we read about lost a subdomain's DNS record entirely — renewal failed quietly for weeks because one name on the certificate no longer resolved.
The dry run tells you which without burning a real attempt:
certbot renew --dry-run
Read the error it prints for the exact domain. Fix the challenge path, not the symptom.
Full write-up with the curl table and the nginx and Apache carve-outs: HTTP-01 died after you forced HTTPS.
3. Nothing is actually running
Certbot installs either a cron job or a systemd timer depending on distro and install method — and it's easy to end up with neither, especially after a migration or a container rebuild.
systemctl list-timers | grep -i certbot ls /etc/cron.d/ | grep -i certbot
Both empty? There's your answer. Re-enable the timer (systemctl enable --now certbot.timer) or add the cron entry, then dry-run.
Full write-up with the systemd, cron, snap, and container paths: The certbot timer is not running.
4. The errors went somewhere nobody reads
The renewal has been failing — loudly, even — but the noise went to a place no human looks. The classic, quoted verbatim from someone who lived it: the cron job was emailing its errors to root on a box with no mail server. Weeks of failure notices, delivered to nowhere.
grep -i "fail\|error" /var/log/letsencrypt/letsencrypt.log | tail -20 sudo mail # you might be surprised
If your alerting depends on local mail, a log nobody tails, or a Slack webhook that got rotated, you don't have alerting. You have a diary.
Full write-up with the log forensics and the off-box alert routing: Renewal failed. Root mail never left the box.
5. Rate limits
Let's Encrypt caps issuance per registered domain per week. A re-issue loop in a pipeline, a burst of new subdomains, or repeated failed-then-retried renewals can exhaust it — and then a legitimate renewal gets refused at the worst moment. The log says too many certificates already issued. The fix is to find the loop, not to retry harder; the limit resets on a rolling window. Full walkthrough: too many certificates already issued.
Disk fine, browser angry?
If disk notAfter is fine but browsers warn, check chain and reload, not certbot. Run a full chain check against what your server sends — an expired or missing intermediate fails visitors while your own certificate is valid.
The structural fix
Notice what all five have in common: the failure was invisible from inside the box. The cron exited, the log line was quiet, the mail went to nowhere. Every one of them is obvious from the outside — one daily check of the certificate your server actually serves would have caught each of these with weeks to spare.
These five are the certbot-specific slice. The wider question — what to watch beyond expiry, why CT-based expiry alerts are noise, and how to survive 47-day certificates without drowning in alerts — is its own practical guide to SSL monitoring in 2026. And if you're choosing between ways to run the check itself, we compared the monitoring tools honestly, from a cron one-liner to paid SaaS.
That check is free to build yourself: the step-0 one-liner in a cron on a different machine, alerting somewhere humans actually look. That's genuinely fine at small scale. For a one-off answer in a browser, the SSL expiry checker reads the live date and days remaining.
If you'd rather not own another cron job, CertPost watches the served certificate — chain, hostname and expiry — and emails you before it matters. Start free monitoring: three certificates, no card. Either way: watch the wire, not the log.
