On a cron-based install, certbot's output — including its failure output — gets mailed to root on the local machine. On most servers, root mail goes nowhere: no relay, no forwarding, no one checking. On a systemd-timer install it is quieter still: nothing is mailed at all, and the errors sit in a journal nobody opens.
The renewal has been failing. The notices have been piling up in a mailbox you have never opened, or a journal you have never read. The certificate expires on a Thursday.
One safety net you may remember no longer exists: Let's Encrypt stopped sending certificate expiry emails in June 2025. The address on your ACME account gets no warning.
Prove it
Check whether renewal has actually been failing:
sudo grep -iE "fail|error|problem|exception" /var/log/letsencrypt/letsencrypt.log | tail -30
If you see lines like:
2026-07-14 03:12:01,847:ERROR:certbot._internal.renewal:All renewals failed. 2026-07-14 03:12:01,848:ERROR:certbot._internal.renewal:The following renewals failed: /etc/letsencrypt/renewal/yourdomain.com.conf
certbot has been failing quietly for days or weeks. The log tells you when it started.
On a systemd install, the journal has the same story:
sudo journalctl -u certbot.service --since "-30 days" | grep -iE "fail|error" | tail -20
And on a cron install, check whether local root mail has been piling up:
sudo mail
If you see a queue of unread messages from certbot or cron, those are the failure notices. Type q to quit without deleting them.
Fix: read the actual renewal error first
Before changing alert routing, read what the failure actually is. A dry-run shows the current error without consuming a rate-limit attempt:
sudo certbot renew --dry-run
The error will usually be one of:
Connection refusedorTimeouton port 80 — the HTTP-01 challenge is blocked or redirected. See HTTP-01 died after you forced HTTPS.DNS problemorNXDOMAIN— a DNS-01 credential rotated or the zone migrated.too many certificates already issued— a rate limit hit by a re-issue loop.certbot: command not found— the snap or package was removed during an OS upgrade. See The certbot timer is not running.
Fix the underlying failure first. Changing alert routing while the renewal is broken just means you hear about the same failure sooner.
Fix: route alerts off-box
Once the renewal works again, point failure notices somewhere a human reads.
Option 1: the exit-code wrapper (works everywhere)
certbot renew exits non-zero when any renewal fails. Wrap the scheduled run so a failure fires a webhook to a channel you actually read:
# in the cron entry, or a small wrapper script the timer calls
certbot renew -q || curl -s -X POST https://your-webhook-url \
-H "Content-Type: application/json" \
-d "{\"text\": \"certbot renew failed on $(hostname) — check /var/log/letsencrypt/letsencrypt.log\"}"
On a systemd install, the same idea is an OnFailure= unit attached to certbot.service, pointing at a one-shot service that sends the webhook.
Option 2: make cron mail actually deliver
If you want to keep local mail as the channel, it needs two things: a MAILTO in the cron file pointing at a real address, and a relay that can actually send. On modern Debian and Ubuntu the lightweight relay is msmtp (ssmtp is gone from the archives):
sudo apt install msmtp-mta
Configure /etc/msmtprc with your SMTP provider, then alias root's mail to a real address in /etc/aliases:
root: you@yourdomain.com
This also catches cron failure notices from every other job on the box.
A note on the certbot account email: the email = in /etc/letsencrypt/cli.ini is your ACME account address. It is not a renewal-failure alert channel, and since Let's Encrypt ended expiry emails it receives no warnings at all. Setting it does not solve this problem.
Make it permanent
Routing alerts is one layer, and it only fires when certbot itself runs and detects a failure. It cannot catch the reload failure — where certbot exits 0 but the server never loads the new file — because there is no failure to report.
The durable fix is checking the certificate your server actually serves, from outside, on a schedule you do not have to maintain yourself.
Run this from a different machine daily:
echo | openssl s_client \ -connect yourdomain.com:443 \ -servername yourdomain.com 2>/dev/null \ | openssl x509 -noout -dates
If notAfter is inside 21 days, your renewal or reload chain broke and did not alert you — a healthy Let's Encrypt cycle renews at 30 days out.
Check what your server is serving right now
Before you move on: verify the certificate currently on the wire matches what you expect.
The SSL expiry checker reads the live cert off port 443 — or any TLS port — with no account. If the expiry date is closer than your last renewal should allow, the fix above did not fully close the gap.
CertPost runs that check on a schedule — daily on the free tier, hourly on Team — and emails or webhooks you at 30, 14, 7, and 1 days, before a renewal failure becomes a browser warning. Three certificates free, no card.
Related failures
- Certbot says it renewed. Your server is still serving the old cert. — the disk-vs-wire check that catches silent failures
- nginx reloaded nothing. Certbot still exited 0. — when the renewal succeeds but the server never loads the new file
- The certbot timer is not running. — when no renewal is happening at all
Next in this series: Let's Encrypt rate limits, and what to do when too many certificates already issued blocks a legitimate renewal.
