Why did my SSL certificate expire without warning? Because browsers never warn you before expiry — they only block after — and your auto-renew failed silently: the cron never ran, the firewall blocked the ACME challenge, DNS didn’t propagate, or the server never reloaded the new cert. Since 2015, Let’s Encrypt certificates last only 90 days (soon 47), so one missed renewal is an outage, and the “expiration email” often lands in spam or goes to an old address. I’ve seen a cron that ran at 2 am but the server was down for maintenance at 2 am — every renewal failed for 30 days and no one noticed until Chrome showed NET::ERR_CERT_DATE_INVALID. This guide explains why SSL certificates expire without warning, the 7 silent failure modes that kill auto-renew, how to check expiry in 10 seconds, and how to set up auto-renew with monitoring that actually warns you before the next expiry — not after.
- Why no warning: Certificates have
notBefore → notAfter(90 days for Let’s Encrypt) per RFC 5280 X.509 — browsers only check on visit, no proactive alert. Let’s Encrypt expiration emails go to the address in the account, often old or spam-filtered, and stop if you renew via a new account. See Let’s Encrypt Expiration Emails. - 7 silent killers: 1) Cron/systemd timer not running/enabled, 2) Firewall blocks port 80 for HTTP-01 challenge, 3) DNS-01 TXT not propagated or CNAME wrong, 4) Certbot renews but server not reloaded (
nginx -s reloadmissing), 5) Wrong cert path (Apache points to oldcert.pemnotfullchain.pem), 6) Rate limits / duplicate certs, 7) Email alerts to spam/old address. - Check expiry in 10 sec:
openssl x509 -enddate -noout -in /etc/letsencrypt/live/domain/fullchain.pemorecho | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -datesor browser padlock → Certificate → Valid to, or SSL Labs. See Let’s Encrypt How It Works + MDN TLS. - Fix auto-renew that works: Use
certbot --nginxor--apachewith--deploy-hook "systemctl reload nginx", enablecertbot.timer(not just cron), allow port 80, set DNS API credentials for wildcard, and test withcertbot renew --dry-run. See DigiCert How to Avoid Expiration. - Monitor that warns you: Don’t rely on email — add external: UptimeRobot/Better Stack check for 30/14/7 days, SSL Labs weekly scan, and calendar reminder at 60 days (Let’s Encrypt) or 30 days before expiry. More at Toolwasp.
Why Do SSL Certificates Expire Without Warning?
Because expiry is baked into the certificate itself — browsers check it only when you visit, and the only “warning” system is an email that often never reaches you.
Every certificate has two dates inside it: notBefore (when it becomes valid) and notAfter (when it dies) — that’s X.509 per RFC 5280. Let’s Encrypt sets notAfter = notBefore + 90 days (soon 47) by design to force automation. Browsers read notAfter on each TLS handshake and compare to your device clock — if now > notAfter, they show NET::ERR_CERT_DATE_INVALID and block. There’s no “your cert expires in 7 days” toast — the first warning is the outage itself. That’s why you need external monitoring.
Let’s Encrypt does send expiration emails, but per Expiration Emails, they go to the email on the ACME account that created the cert, not to every admin; they’re rate-limited (one per cert, at 20, 10, 1 days), and they stop if you issued a newer cert for the same names via a different account (common when you rebuild a server). I’ve seen teams create certs via certbot --nginx on server A (email admin@old.com), then rebuild on server B with admin@new.com — the old email gets warnings for the old cert, the new email gets none for the new cert, and no one watches the old inbox — so the new cert expires unwatched.
Why 90 Days (And Soon 47) — Not a Year
Short lifetimes limit damage from mis-issuance and force automation. A 1-year cert that’s mis-issued is trusted for a year; a 90-day one is trusted for 90 days. Let’s Encrypt chose 90 days to make manual renewal painful enough that you automate — and automation is what prevents the “without warning” surprise. The industry will tighten to 47 days by 2029, so manual is already untenable. See How It Works (ACME challenge → issuance).
The 7 Silent Failure Modes That Kill Auto-Renew
Auto-renew is not “set and forget” — it’s 7 places that can silently fail while cron says “success.”
| # | Silent Killer | What Happens | Why No Warning | Fix |
|---|---|---|---|---|
| 1 | Cron / Timer Not Running | Cron job exists but server was off at 2 am, or certbot.timer not enabled | Cron failure is silent unless you log it | systemctl enable --now certbot.timer |
| 2 | Firewall Blocks Port 80 | HTTP-01 challenge needs port 80 open to Let’s Encrypt | Renew logs show “connection refused” but cron discards output | Allow 80, test curl http://yourdomain/.well-known/ |
| 3 | DNS-01 Not Propagated | TXT _acme-challenge not visible to Let’s Encrypt | Challenge fails, but you see no UI | Use DNS API plugin, wait TTL |
| 4 | Server Not Reloaded | Certbot writes new fullchain.pem but Nginx still serves old in memory | certbot renew says “success” but browser still shows old expiry | --deploy-hook "systemctl reload nginx" |
| 5 | Wrong Path (Apache/Nginx) | Vhost points to cert.pem not fullchain.pem or old copy | Renew succeeds but vhost never uses new chain | Point to fullchain.pem + privkey.pem |
| 6 | Rate Limits / Duplicates | 5 duplicate certs/week → blocked for a week | Error in log but cron still “success” for next domain | Use staging, consolidate SANs |
| 7 | Email to Spam/Old | Let’s Encrypt warning to spam or old address | No inbox check | External monitor, not email |
1. Cron / Timer Not Running — The Classic
Certbot installs a cron or systemd timer, but it’s not enabled or the server was down at the scheduled time. Check: systemctl status certbot.timer should show active and next elapse; crontab -l | grep certbot should show a line. If you use Docker, the host cron doesn’t run inside the container — you need a sidecar or host timer. Test now: certbot renew --dry-run — if it says “no renewals attempted,” your line is wrong; if “failed,” see next.
2. Firewall Blocks Port 80 — HTTP-01 Needs It
Let’s Encrypt HTTP-01 must fetch http://yourdomain.com/.well-known/acme-challenge/TOKEN — if port 80 is firewalled, it fails. Many close port 80 after getting TLS, thinking “we only need 443.” You need 80 open for renewal — the challenge is HTTP, not HTTPS. Test: curl -v http://yourdomain.com/.well-known/acme-challenge/test from outside should hit your server, not firewall. Fix: allow 80, or switch to DNS-01 (needs API).
4. Server Not Reloaded — The Most Maddening
Certbot can succeed and the browser still shows the old expiry — because Nginx/Apache reads certs at startup and keeps them in memory. You renewed /etc/letsencrypt/live/domain/fullchain.pem on disk, but nginx -s reload never ran, so the worker still serves the old cert from RAM. The fix is a deploy hook: certbot --nginx --deploy-hook "systemctl reload nginx" or for Apache "systemctl reload apache2". Verify: after renew, openssl s_client -connect yourdomain:443 -servername yourdomain 2>/dev/null | openssl x509 -noout -dates should show new notAfter — if not, reload failed.
Why “It Worked Last Time” Is the Trap
Manual renewal works once, so you assume it will work next time — but next time your firewall rule changed, or Cloudflare proxy is now orange-clouded (so HTTP-01 hits Cloudflare, not your origin), or you added a new subdomain not in the cert’s SAN. I’ve seen a site renew fine for a year, then add shop.example.com to the nginx server_name without reissuing the cert to include it — renewal still succeeded for example.com, but shop.example.com stayed expired and no one noticed until customers complained. The fix is to treat certificate SAN as code: list every hostname you serve in one cert (or one cert per host) and verify certbot certificates shows all of them. Explore certificate checks at Toolwasp.
How to Check Expiry in 10 Seconds — 4 Ways
Don’t wait for email — check the cert you’re actually serving.
- Browser padlock (1 sec, but check the right host): Chrome → padlock → Certificate is valid → Details → Valid to — shows what the server sent you, not what’s on disk. Check both
example.comandwww.example.com— they can differ. - OpenSSL served (truth):
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates # notBefore=Sep 3 00:00:00 2026 GMT # notAfter=Dec 2 00:00:00 2026 GMT ← add calendar reminder at -30 days # On disk (may differ if not reloaded): openssl x509 -enddate -noout -in /etc/letsencrypt/live/example.com/fullchain.pem - Certbot (what it thinks):
certbot certificates # Should list every domain, expiry, and path — if your domain is missing, it was issued under different name certbot renew --dry-run # tests renewal without counting against rate limits - SSL Labs (external truth):
Visit SSL Labs → enter
example.com→ see “Certificate: 100%” + notAfter + chain issues. It’s the external view Google sees — if SSL Labs says expires in 5 days but your disk says 60, you have a reload/path bug.
Why “Disk vs Served” Mismatch Is the Tell
If openssl x509 -enddate -in /etc/letsencrypt/live/domain/fullchain.pem says Dec 2 but s_client says Sep 5 (yesterday), you renewed but didn’t reload or you’re serving the wrong vhost file — that’s failure mode #4 or #5. Fixing the reload hook fixes this class forever.
How to Set Up Auto-Renew That Actually Works — The Reliable Way
Use the timer, allow port 80, reload on deploy, and test dry-run — then you can ignore it.
Option A: Certbot with Nginx/Apache (Most Common, Recommended)
# Install (Ubuntu/Debian)
sudo apt update && sudo apt install certbot python3-certbot-nginx
# Or python3-certbot-apache
# Issue + enable auto-renew with reload hook (one-time)
sudo certbot --nginx -d example.com -d www.example.com \
--deploy-hook "systemctl reload nginx"
# For Apache:
# sudo certbot --apache -d example.com -d www.example.com \
# --deploy-hook "systemctl reload apache2"
# Enable timer (not just cron)
sudo systemctl enable --now certbot.timer
sudo systemctl status certbot.timer # should show active + next elapse
# Test without hitting rate limits
sudo certbot renew --dry-run
# Should show: Congratulations, all simulated renewals succeeded
Why this works: --nginx handles HTTP-01 on port 80 automatically, and --deploy-hook ensures the new cert is loaded. The timer runs twice daily and renews only when <30 days remain — no spam, no missed window. See How It Works (ACME) and DigiCert for why reload matters.
Option B: DNS-01 for Wildcards or Port 80 Blocked
If you need *.example.com or port 80 is closed, use DNS-01: Certbot writes a TXT _acme-challenge.example.com via your DNS API (Cloudflare, Route 53), Let’s Encrypt checks DNS. Example with Cloudflare:
sudo apt install certbot python3-certbot-dns-cloudflare
# Create /etc/letsencrypt/cloudflare.ini with your API token (chmod 600)
sudo certbot certonly --dns-cloudflare \
--dns-cloudflare-credentials /etc/letsencrypt/cloudflare.ini \
-d example.com -d "*.example.com" \
--deploy-hook "systemctl reload nginx"
DNS-01 avoids port 80 but requires API credentials — store them as 600 and rotate. Test again with --dry-run.
Option C: Cloudflare Origin + Edge (If Behind Cloudflare)
If you’re behind Cloudflare, you have two certs: edge (Cloudflare serves visitors) and origin (Cloudflare → your server). Edge is auto-renewed by Cloudflare; origin you manage. Set Cloudflare SSL to Full (Strict) and install an origin cert from Cloudflare (long-lived) or Let’s Encrypt on origin — the padlock visitors see is edge, but origin must also be valid or Cloudflare shows Error 526. This split is why “SSL Labs says valid but Cloudflare says invalid” happens — they check different certs.
Monitoring That Actually Warns You Before the Outage
Don’t rely on Let’s Encrypt email — add external checks that you control.
| Monitor | What It Checks | Warns At | Why It Works |
|---|---|---|---|
| UptimeRobot / Better Stack | Served cert expiry (external) | 30, 14, 7, 3, 1 days | Checks what browsers see, not disk — catches reload/path bugs |
| SSL Labs Weekly | Chain + protocols + expiry | Manual weekly scan | Catches chain + weak TLS too |
| Calendar | Your calendar | 60 days after issue (Let’s Encrypt) or 30 days before | No tool to fail — you see it |
| Certbot timer log | Renewal attempts | On failure | Catches silent cron fails |
Set all four: timer log for immediate, external monitor for served truth, SSL Labs for chain, calendar for human backup. I set UptimeRobot to check https://example.com with keyword “Certificate expires in” and alert via Slack/email at 30/14/7/3/1 — the 30-day alert is the real warning; the 3-day is the panic. Also monitor certbot.timer via systemctl status in your uptime check — if the timer is inactive, you’ll know before expiry.
Why Disk vs Served Monitoring Matters
Disk check (openssl x509 -enddate -in /etc/letsencrypt/live/.../fullchain.pem) says what you think you serve; served check (s_client or UptimeRobot) says what browsers actually get. Failure modes #4 and #5 make these differ — you can have a fresh cert on disk and an expired one served. Always monitor served, not just disk.
Practice Lab — Make Renewal Fail, Then Fix It (10 Minutes, Staging)
# Lab — on staging, not prod
# 1) Simulate firewall block (failure mode #2)
sudo ufw deny 80
sudo certbot renew --dry-run # → connection refused → fail (expected)
sudo ufw allow 80
# 2) Simulate missing reload (failure mode #4)
sudo certbot renew --dry-run # should succeed
# Check served vs disk:
echo | openssl s_client -connect staging.example.com:443 -servername staging.example.com 2>/dev/null | openssl x509 -noout -dates
openssl x509 -enddate -noout -in /etc/letsencrypt/live/staging.example.com/fullchain.pem
# If they differ after real renew, add --deploy-hook "systemctl reload nginx"
# 3) Set up monitoring
# Add https://www.ssllabs.com/ssltest/analyze.html?d=staging.example.com to weekly calendar
# Add UptimeRobot keyword monitor for "Certificate expires in" at 30 days
You just triggered the top 2 silent failures and proved why “dry-run success” ≠ “served success” — the deploy hook and external monitor close that gap. Do this once on staging and the next real renewal is boring.
Frequently Asked Questions
Why did my SSL certificate expire even though I have auto-renew?
Because auto-renew has 7 silent failure modes that look like success: cron/timer not enabled, firewall blocks port 80, DNS challenge not propagated, server not reloaded after renew, wrong vhost path, rate limits, or email alerts to spam. The most common I see is #4 — certbot renew says success (disk updated) but nginx -s reload never ran, so browsers still see the old expiry. Check systemctl status certbot.timer and openssl s_client served vs disk — if they differ, it’s a reload/path bug, not a renewal bug. See Let’s Encrypt.
How long do SSL certificates last now?
Let’s Encrypt is 90 days (soon 47); commercial CAs may be 1 year (soon also shorter per industry). The 90-day lifetime is intentional to force automation — manual renewal every 90 days is error-prone, which is exactly why it “expires without warning” when you try manual. Automate and monitor served expiry, not just issuance. See RFC 5280 for the notAfter field and DigiCert for the lifetime tightening timeline.
Will Let’s Encrypt email me before expiry?
Sometimes — it sends at 20, 10, and 1 days to the address on the ACME account that created the cert, but only if you haven’t already renewed that exact cert via any account, and only once per cert. If you rebuilt the server with a new account, the old email gets warnings for the old cert, the new email gets none for the new cert, and the warning is useless. Don’t rely on it — use external monitoring (UptimeRobot) that checks served cert independently. See Expiration Emails.
How do I check when my certificate actually expires?
Check what browsers see, not just disk: echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null | openssl x509 -noout -dates shows served notAfter. Compare to openssl x509 -enddate -in /etc/letsencrypt/live/domain/fullchain.pem (disk). If they differ, it’s a reload/path bug. Also click padlock → Certificate → Valid to, and scan at SSL Labs. See MDN TLS.
Do I need to renew manually if I have auto-renew?
No — if certbot.timer is active and certbot renew --dry-run succeeds, it will auto-renew at <30 days. But verify the timer is enabled (systemctl enable --now certbot.timer) and the deploy hook reloads the server. Test dry-run after every server rebuild — rebuilds often lose the timer.
Why does my site show “Your connection is not private” even though the cert is valid?
Often it’s not the cert — it’s a mixed content or wrong hostname: the cert is for example.com but you visited www.example.com not in SAN, or the page loads an image over http:// (see our Not Secure guide), or your device clock is wrong. Check openssl s_client -connect example.com:443 -servername www.example.com and DevTools → Console for mixed content. See Cloudflare What Is SSL.
Can I get a certificate that doesn’t expire?
No — all public certificates expire by design; long-lived certs increase risk if mis-issued. The solution is automation, not longer life. Use Let’s Encrypt with auto-renew + deploy hook + external monitoring — that’s effectively “never expires” because it renews itself and you’re alerted if it doesn’t. For internal systems, consider a private CA with longer life, but still automate.
What’s the difference between certbot certificates and “the certificate” my browser sees?
Certbot manages files on disk (/etc/letsencrypt/live/domain/fullchain.pem); your web server (Nginx/Apache) loads those files into memory at startup. If you don’t reload the server after renewal, the disk file is new but the served cert is old — that’s why external checks (SSL Labs, s_client) are the truth, not certbot certificates alone.
Last updated: September 3, 2026 • Author: Toolwasp Team • Sources verified Sep 3, 2026: Let’s Encrypt Expiration Emails, Let’s Encrypt How It Works, SSL Labs, DigiCert How to Avoid, Cloudflare What Is SSL, RFC 5280, MDN TLS. More at Toolwasp.