Let's Encrypt made certificates free and renewal automatic — and created a new genre of outage: the certificate that expires with automation configured. The design leaves a deceptively comfortable margin: certificates live 90 days, certbot starts trying to renew at 30 days remaining, and when renewal breaks, nothing happens. No error page, no failed deploy — the site works perfectly for up to 30 more days while the countdown runs. Then, at the least convenient hour, every browser shows NET::ERR_CERT_DATE_INVALID and every API client hard-fails TLS verification at once.

That 30-day silent window is the whole problem, and also the whole opportunity: renewal failures are the most detectable outage in infrastructure, because you get a month of warning — if anything is watching. This guide sets up the two layers that watch: expiry monitoring on the certificate your server actually serves, and a heartbeat on the renewal job itself. Our general SSL certificate monitoring guide covers the wider topic; this one is specifically about keeping the ACME machinery honest.

How renewals actually break

  • The timer stops running. The certbot systemd timer wasn't re-enabled after a server rebuild, the cron entry didn't survive a migration, or the container image running certbot was retired. Renewal isn't failing — it isn't being attempted. (systemd timers have their own silent failure modes; our systemd timer guide covers them.)
  • HTTP-01 challenges start failing. The challenge requires Let's Encrypt to fetch /.well-known/acme-challenge/… over port 80. A firewall tightened to 443-only, a new redirect-all-HTTP rule, a moved webroot, or a rewritten proxy config all break it — usually as a side effect of unrelated work months after setup. Reverse-proxy changes are the classic trigger; see the reverse proxy monitoring guide.
  • DNS-01 credentials expire. Wildcard certificates renew via DNS API tokens — which get rotated, scoped down, or revoked when someone audits the DNS provider's access list.
  • Renewed on disk, never reloaded. The sharpest edge: certbot renew succeeds, writes the new certificate, and nginx keeps serving the old one from memory because no --deploy-hook reloads it. certbot's logs say healthy; browsers disagree in 30 days.
  • Rate limits and ACME account issues. Rarer, but a botched migration that re-issues repeatedly can hit Let's Encrypt's rate limits, locking you out during the exact window you need a renewal.

Notice what these have in common: none of them produce a user-visible symptom on the day they happen. Every one of them produces the same symptom 30 days later.

Layer 1: monitor the certificate your server serves

This is the catch-all. An external SSL monitor performs a real TLS handshake and reads the expiry of the certificate actually presented — which makes it immune to every failure mode above, including renewed-but-not-reloaded, because it sees exactly what browsers see. Point CronAlert at each HTTPS endpoint (SSL monitoring is included on the free plan, alongside the HTTP check you probably already run) and the math does the rest: a certificate showing fewer than ~25 days remaining means renewal has been failing for days, and you have weeks — not minutes — to fix it.

Cover the non-obvious hostnames while you're at it: api., www. vs apex, webhook receivers, mail-adjacent services, staging domains that production code calls. Each subdomain can be a separate certificate with a separate renewal path, and the forgotten ones expire first — an inventory habit our SSL guide walks through.

Layer 2: heartbeat the renewal job itself

Expiry monitoring gives you a guaranteed backstop with weeks of margin. The heartbeat tells you the day the machinery breaks — and catches the "timer never runs" class that certbot's own logging can't report, because nothing invokes certbot to log anything. The packaged timer runs certbot renew twice daily; add a ping that fires only on success:

# Option A: in the cron entry (if you use cron)
17 3,15 * * * certbot renew --quiet && curl -fsS --retry 3 \
  https://cronalert.com/api/heartbeat/TOKEN

# Option B: as a --post-hook (runs after EVERY renew attempt)
# /etc/letsencrypt/renewal-hooks/post/heartbeat.sh
#!/bin/sh
curl -fsS --retry 3 https://cronalert.com/api/heartbeat/TOKEN

Set the monitor's expected interval to 12 hours with a few hours of grace. One caution on Option B: hooks in renewal-hooks/post/ run after every certbot renew completes, but if a challenge fails certbot still exits non-zero after running post-hooks on some versions — the && curl form in the cron line ties the ping to the exit code unambiguously, which is why it's the shape we recommend when you control the schedule. If you're on the systemd timer, the cleanest equivalent is an ExecStartPost on the certbot service, exactly as in our systemd timer guide. Heartbeat monitors are on every paid CronAlert plan, from $5/mo.

Fix the reload gotcha while you're here

The renewed-but-not-reloaded failure deserves its own paragraph because it defeats naive monitoring: the renewal job succeeds (heartbeat pings happily) while the served certificate marches toward expiry. Two defenses, use both:

# /etc/letsencrypt/renewal-hooks/deploy/reload.sh
# Runs ONLY when a certificate was actually renewed (~every 60 days)
#!/bin/sh
systemctl reload nginx

First, the deploy-hook above — the reload belongs in deploy, not post, so it fires exactly when a new certificate lands and never otherwise. Second, Layer 1 already covers you: because the external monitor reads the served certificate, a reload that stops happening shows up as expiry creeping under 30 days even while renewals "succeed." When the two layers disagree — heartbeat green, expiry shrinking — the reload is broken. That disagreement is itself diagnostic, which is why running both layers beats either alone.

Wildcards, DNS-01, and multi-server setups

Wildcard certificates renew via DNS-01, so the failure surface moves to the DNS provider's API: expired tokens, tightened scopes, propagation timeouts. The two-layer setup doesn't change — heartbeat on the renewal job, expiry on every hostname the wildcard serves — but add the DNS provider's status to your third-party dependency watchlist, since their API outage at your renewal moment is a real (if unlucky) path. For fleets where one host renews and distributes certificates to others, put the heartbeat on the distribution step, not just the renewal — a cert that renews but never ships to the load balancer is the fleet version of the reload gotcha.

Set it up in ten minutes

  • Create a CronAlert account — SSL expiry monitoring is on the free plan; heartbeats start on Pro at $5/mo.
  • Add HTTPS monitors for every hostname with a certificate — apex, www, api, webhooks, staging. SSL monitoring alerts as expiry approaches.
  • Add a heartbeat monitor with a 12-hour expected interval; append && curl -fsS --retry 3 https://cronalert.com/api/heartbeat/TOKEN to the renewal cron line (or ExecStartPost on the certbot service).
  • Confirm your reload lives in renewal-hooks/deploy/, not post/.
  • Test both layers: run certbot renew --dry-run (no ping — dry runs shouldn't count as success unless you wire them to), then force the real cron line once and confirm the ping arrives.
  • Route alerts where the team lives: email, Slack, Discord, Teams, Telegram, PagerDuty, or push.

Frequently asked questions

Why did my certificate expire even though certbot auto-renewal was set up?

The renewal machinery broke silently — dead timer, failing HTTP-01 challenge, expired DNS token, or a renewal that succeeded on disk while the server kept serving the old certificate. All four produce zero symptoms until expiry day.

How do I know if certbot renew is actually running?

Heartbeat it: && curl to a unique URL after each successful run, expected every 12 hours. A dead timer becomes an alert within a day instead of an outage within a month.

What's the difference between --post-hook and --deploy-hook?

post runs after every renew attempt (put the heartbeat adjacent to it); deploy runs only when a certificate actually renewed (put the nginx reload there). Swapping them breaks both jobs.

Why is my server serving an old certificate after certbot renewed it?

No reload — nginx serves the in-memory copy until told otherwise. Add the deploy-hook, and rely on external SSL monitoring, which reads the served certificate rather than the file on disk.

How far in advance should I be alerted?

30 days out is the actionable alert for Let's Encrypt — under ~25 days remaining means renewal is already failing. 14- and 7-day reminders catch fixes that didn't take.

Turn a 3 AM outage into a Tuesday-afternoon ticket

Every certificate expiry was detectable for a month before it happened. Two monitors per domain — SSL expiry on what's served, a heartbeat on the renewal job — plus one deploy-hook convert the whole failure class into calm, early tickets. Create a free CronAlert account, point SSL monitoring at your domains today, and add the renewal heartbeat with the $5/mo Pro plan.

Related reading: SSL certificate monitoring, monitoring systemd timers, cron job heartbeat monitoring, and monitoring Nginx, Traefik, and Caddy.