Blog
Auto-renewal does not stop certificates from expiring. Here is why renewals fail, which warning thresholds make sense and what to check beyond the expiry date.
SSL certificate expiry monitoring means checking the certificate that each hostname and port actually serves, from outside your infrastructure, and alerting with enough lead time to fix a failed renewal. For auto-renewed 90-day certificates, a sensible setup warns at 21 days, escalates at 7 and pages someone at 3. Monitoring only the date is not enough, though. You also need to verify the chain, the hostname match and that every server behind a name serves the new certificate.
This guide covers why certificates still expire on systems with auto-renewal, how to pick warning thresholds that are neither noisy nor too late, and the concrete checks and commands that catch problems before browsers and API clients do.
Auto-renewal moves the failure point. Instead of a human forgetting a date, a process fails quietly and nobody looks at its output. These are the patterns that cause most expired certificates on setups that were supposed to be automatic.
ACME clients like certbot retry on a schedule, typically twice a day via a systemd timer or cron. Each attempt can fail for reasons that have nothing to do with the client itself:
/.well-known/acme-challenge/ somewhere else, or the domain moved behind a CDN or load balancer that does not forward the challenge path.The renewal log fills with errors, but nobody reads it. Let's Encrypt has also discontinued its expiry reminder emails, so the old safety net of a warning in someone's inbox is gone.
This one is common and confusing. Certbot writes a fresh certificate to disk, certbot certificates shows 89 days remaining, and the site still serves the old one. Nginx, HAProxy, Postfix and most other daemons read certificates at startup and keep them in memory until reloaded. Without a deploy hook, the new file sits unused.
# /etc/letsencrypt/renewal-hooks/deploy/reload.sh
#!/bin/sh
systemctl reload nginx
systemctl reload postfixThe same applies to containers that copy certificates in at build time and to Java keystores that need a conversion step after renewal.
Auto-renewal covers the certificates you configured. It does nothing for the ones you forgot exist: a staging subdomain set up by a former colleague, a status or docs subdomain on a different host, the mail server on ports 465 and 993, an internal API exposed on 8443, or a certificate uploaded manually to a load balancer or CDN years ago. These rarely appear in the main deployment config, so they rarely appear in monitoring either.
A server must send its leaf certificate plus the intermediate certificates up to a trusted root. Configuring cert.pem instead of fullchain.pem produces a chain with the leaf only. Desktop browsers often hide the problem because they cache intermediates or fetch them on their own. curl, mobile apps, webhooks from third parties and server-side HTTP libraries do not, so they fail with verification errors while the site looks fine in your browser. Chain issues also appear when a CA rotates its intermediates and a server still sends an old bundle.
The right threshold depends on how the certificate is renewed. An alert should mean that the normal process has already failed, not that it is about to run.
For auto-renewed 90-day certificates (certbot renews when 30 days remain by default):
Do not alert at 30 days for auto-renewed certificates. That fires on every renewal cycle that runs a few hours late and trains people to ignore the alert.
For manually renewed certificates with longer lifetimes, the bottleneck is the human process: purchase approvals, domain validation emails, someone with access to the load balancer. Use 45, 30, 14 and 7 days, and make the first alert create a ticket with an owner.
Certificate lifetimes are also getting shorter: CA/Browser Forum rules will reduce maximum validity in steps toward 47 days by 2029. Shorter lifetimes leave less slack, which makes external checks on what is served more valuable, not less.
An expiry check that only reads notAfter misses most of the failures above. Monitor the certificate that clients actually receive, not the file on disk or the renewal log. A useful check verifies:
OpenSSL covers almost everything. Always pass -servername so SNI is sent, otherwise you may see the server's default certificate instead of the one for your hostname.
Expiry date, subject and issuer:
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -enddate -subject -issuerExit with a non-zero code if the certificate expires within 21 days (1814400 seconds):
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -checkend 1814400Subject Alternative Names (OpenSSL 1.1.1 or newer):
echo | openssl s_client -connect example.com:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -ext subjectAltNameChain verification: look for Verify return code: 0 (ok). A code like 21 (unable to verify the first certificate) means the intermediate is missing.
echo | openssl s_client -connect example.com:443 -servername example.com -showcerts 2>/dev/null \
| grep -E 'Verify return code|^ [0-9] s:'Check a specific backend IP while still sending the right hostname:
echo | openssl s_client -connect 203.0.113.10:443 -servername example.com 2>/dev/null \
| openssl x509 -noout -enddateMail and other STARTTLS services:
echo | openssl s_client -connect mail.example.com:587 -starttls smtp 2>/dev/null \
| openssl x509 -noout -enddateA minimal loop for a list of targets, suitable as a stopgap or a sanity check:
#!/usr/bin/env bash
WARN_DAYS=21
for target in example.com:443 api.example.com:443 mail.example.com:465; do
host=${target%:*}
if ! echo | openssl s_client -connect "$target" -servername "$host" 2>/dev/null \
| openssl x509 -noout -checkend $((WARN_DAYS*86400)) >/dev/null; then
echo "WARN: $target expires within $WARN_DAYS days or returned no certificate"
fi
doneOn the renewal side, certbot renew --dry-run tests the full challenge flow against the staging environment without issuing a real certificate. Run it after any change to firewalls, redirects or DNS.
%.example.com on a CT search service such as crt.sh lists every hostname a certificate was issued for, including the ones nobody remembers. Compare that list with what you monitor.certbot renew --force-renewal and confirm the served certificate changes.Expiry checks complement, but do not replace, regular HTTP uptime checks. A certificate can be perfectly valid on a server that returns errors. If you want the terminology around uptime, SLOs and synthetic checks in one place, the monitoring glossary has short definitions.
If you would rather not maintain scripts, m0nitor offers SSL certificate expiry as one of its monitor types, alongside HTTP(S), TCP, DNS and heartbeat monitors. Checks run from distributed probes in several regions, and an outage is confirmed by multiple locations before an alert fires. Alerts can be sent to channels such as email, Slack, Microsoft Teams, Telegram, SMS or PagerDuty. Pairing an SSL monitor with a heartbeat monitor on the renewal job covers both sides: the job that should renew and the certificate that should be served. Any other tool or a well-maintained script that runs the checks above does the job too.
For the date alone, once a day is enough. Because a check also catches chain errors and missed reloads after deployments, running it every few hours or alongside your regular uptime checks shortens the time a broken chain goes unnoticed.
No. Let's Encrypt has discontinued its expiry notification emails, so you need your own monitoring to learn about failed renewals before the certificate expires.
Almost always a missing intermediate certificate. Browsers often cache or fetch intermediates themselves, while curl and most HTTP libraries do not. Serve the full chain, for example fullchain.pem instead of cert.pem, and confirm with openssl s_client that the verify return code is 0.
Yes. Any TLS service can be checked with openssl s_client -connect host:port, and services that upgrade with STARTTLS, such as SMTP on port 587, work with the -starttls option. Mail servers and internal APIs on custom ports are among the most commonly forgotten certificates, so include them explicitly.