Renewal failed. Root mail never left the box.

Certbot has been logging renewal errors for weeks. The notices went to root on a server with no mail relay — or to a journal nobody reads. How to find the errors, read what went wrong, and route alerts somewhere that reaches you.

Cover Image for Renewal failed. Root mail never left the box.

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 refused or Timeout on port 80 — the HTTP-01 challenge is blocked or redirected. See HTTP-01 died after you forced HTTPS.
  • DNS problem or NXDOMAIN — 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.

Next in this series: Let's Encrypt rate limits, and what to do when too many certificates already issued blocks a legitimate renewal.