Most sites have had auto-renewing certificates for years, so an expired one feels like a thing of the past. Then a renewal fails quietly in August, nobody looks, and in October visitors get a full-page "Your connection is not private" warning while the checkout page loses every sale.
The renewal tool usually isn't the whole story. The certificate on disk can be fresh while the web server is still handing out the old one from memory. A firewall change can block the challenge. A new AAAA record can send the certificate authority to a server that no longer exists. None of that shows up until a browser complains.
So the habit worth building is simple: check SSL certificate expiry on what the server actually sends, not on what you think it has. Here is how we do it from a terminal, what the output means, and how to tell which part of the renewal broke.
Why "auto-renew is on" isn't enough anymore
Two changes make this more pressing than it used to be.
Certificates are getting shorter. Under the CA/Browser Forum rules, a public TLS certificate issued since March 15, 2026 can be valid for at most 200 days. That drops to 100 days in March 2027 and 47 days in March 2029. Let's Encrypt still issues 90-day certificates by default, moving to 64 days in February 2027 and 45 days in February 2028. Shorter lifetimes mean more renewals, and more chances for one to fail.
The reminder emails are gone too. Let's Encrypt stopped sending expiry notification emails on June 4, 2025. If you were relying on that "your certificate expires in 20 days" message as a safety net, it no longer exists. You need your own check.
The one command worth memorizing
Everything below builds on one small shell function. Put it in your ~/.bashrc:
cert() { echo | openssl s_client -connect "${2:-$1}:443" -servername "$1" 2>/dev/null; }
cert example.com opens a TLS connection and prints the certificate the server presents. The optional second argument lets you aim at a specific IP while still asking for the right name, which is handy before a migration or when you want to skip a CDN: cert example.com 203.0.113.10.
The -servername part matters. One IP often hosts many sites, and the server picks the certificate by the name the client sends (SNI). Recent OpenSSL sends the name by default when you connect to a hostname, but not when you connect to an IP, and older builds don't send it at all. Without it you may be looking at the server's default certificate and wondering why the dates are wrong.
Pipe the result into openssl x509 to read it:
cert example.com | openssl x509 -noout -subject -issuer -dates
cert example.com | openssl x509 -noout -ext subjectAltName
cert example.com | openssl x509 -noout -checkend $((21*86400))
notAfter is the line you care about, and it is always in GMT. -checkend takes seconds and answers one question: will this certificate still be valid that far from now? It prints "Certificate will not expire" and exits 0, or "Certificate will expire" and exits 1, which makes it easy to use in a script. If you prefer a number:
end=$(cert example.com | openssl x509 -noout -enddate | cut -d= -f2)
echo $(( ($(date -d "$end" +%s) - $(date +%s)) / 86400 )) days left
Reading the rest of the certificate
Dates are only one way a certificate can fail. Two more lines in that output are worth a glance every time.
The Subject Alternative Name list is what browsers match against. The old CN field is ignored for that. If www.example.com isn't in the list, anyone who types the www version gets a name error even though the certificate is valid.
The verify result tells you whether the chain is complete:
cert example.com | grep 'Verify return code'
You want 0 (ok). A result like 20 (unable to get local issuer certificate) usually means the server sends only the site certificate without the intermediate. Desktop browsers often paper over that, but curl, payment webhooks and older phones won't. With Let's Encrypt the fix is pointing the server at fullchain.pem instead of cert.pem.
Renewed, but the old one is still being served
This is the failure we see most on self-managed servers. Certbot renews on schedule and writes new files under /etc/letsencrypt/live/, but nginx, Apache, Postfix or a Node app read the certificate once at startup and keep it in memory. Until something reloads them, visitors get the old one.
The certbot nginx and Apache plugins reload the server for you. Certificates issued with certonly, --webroot or --standalone don't, and neither does anything outside the web server, like a mail server using the same certificate.
Comparing the file with the live connection makes it obvious:
The fix for today is a reload. The fix for next time is a deploy hook, which certbot runs only after a successful renewal:
sudo tee /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh >/dev/null <<'HOOK'
#!/bin/sh
systemctl reload nginx
HOOK
sudo chmod +x /etc/letsencrypt/renewal-hooks/deploy/reload-nginx.sh
Add a line for every service that uses the certificate, such as systemctl reload postfix dovecot.
When the renewal itself is failing
If the file on disk is old too, the problem is earlier in the chain. Renewal is a short pipeline, and each stage has its own typical way of breaking:
Start with these four commands:
systemctl list-timers | grep -i certbot
sudo certbot certificates
sudo certbot renew --dry-run
sudo tail -n 50 /var/log/letsencrypt/letsencrypt.log
The first confirms the timer exists and ran recently (it's certbot.timer from the Debian and Ubuntu packages, snap.certbot.renew.timer from the snap). certbot certificates lists every certificate it manages with its expiry date, and --dry-run repeats the whole renewal against the staging servers without touching your real certificate.
The dry run's error message is usually clear. The causes we run into most:
-
Port 80 is closed. The HTTP challenge needs it, even if the site itself redirects everything to HTTPS. A firewall tightened after setup is the usual reason. If you followed our VPS security checklist, make sure
ufw allow 80/tcpis still there. - DNS points somewhere else. The certificate authority connects to whatever A and AAAA records say. A stale AAAA record pointing at an old server is a classic, because your own machine may never use IPv6 and you never notice. Our guide to reading dig output walks through checking both record types.
-
The webroot moved. After a site restructure,
--webroot -wcan point at a folder the web server no longer serves. - The disk is full. Certbot can't write the new files, and the log says so plainly.
Quick reference: what you see and where to look
| What you see | Likely cause | Check with |
|---|---|---|
| Browser says the date is invalid | Renewal failed, or it worked and nothing reloaded |
-dates on the live connection and on the file |
| Name mismatch error, often only on www | Name missing from the certificate, or the wrong site answered |
-ext subjectAltName, and test with -servername
|
| Browser fine, curl or an API client fails | Intermediate certificate not sent | Verify return code |
| Correct on one network, old on another | An old IP still answers through an AAAA or A record |
cert name ip against each address |
Put it on a schedule
A check you run by hand is a check you forget. We wrapped everything above into one read-only script, ssl-expiry-check.sh, in our server toolkit on GitLab. It takes a list of hosts and reports days left, issuer, name match and chain status for each, and with --live-dir /etc/letsencrypt/live it also flags a served certificate that differs from the one on disk. It exits non-zero when something needs attention, so a daily cron entry with chronic only emails you on problems:
30 7 * * * chronic /usr/local/bin/ssl-expiry-check.sh -f /etc/ssl-hosts.txt --live-dir /etc/letsencrypt/live
Set the warning at three weeks. With 90-day certificates renewing at 30 days left, a warning at 21 days means one renewal attempt has already been missed and you still have time to fix it calmly.
The same idea applies to the domain itself, which can expire just as quietly as the certificate. Whether your sites live on shared hosting or on a Pro Gineous VPS, a five-minute check like this beats finding out from a customer.
Originally published on the Pro Gineous blog.



Top comments (0)