DEV Community

Cover image for How to Check SSL Certificate Expiry Before Browsers Start Warning
Pro Gineous
Pro Gineous

Posted on Originally published at blog.progineous.com

How to Check SSL Certificate Expiry Before Browsers Start Warning

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; }
Enter fullscreen mode Exit fullscreen mode

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))
Enter fullscreen mode Exit fullscreen mode

Terminal screenshot of a small cert shell function wrapping openssl s_client, then openssl x509 showing the example.com certificate subject, issuer and dates with notAfter Dec 25 2026, its subject alternative names, checkend reporting Certificate will not expire, and Verify return code 0 (ok)

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
Enter fullscreen mode Exit fullscreen mode

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'
Enter fullscreen mode Exit fullscreen mode

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:

Terminal screenshot where the certificate file in /etc/letsencrypt/live expires Dec 26 2026 but the server still serves one expiring Oct 27, ssl-expiry-check.sh reports FAIL with 4 days left because the served certificate differs from the one on disk, and after systemctl reload nginx the served certificate and the check show Dec 26 and OK

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
Enter fullscreen mode Exit fullscreen mode

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:

Diagram of the certificate renewal path in five stages, timer runs, challenge on port 80, new files under /etc/letsencrypt/live, deploy hook reload and served on port 443, each with the usual ways it breaks such as a closed port 80, a stale AAAA record, a full disk, no reload hook or a missing intermediate certificate

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
Enter fullscreen mode Exit fullscreen mode

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/tcp is 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 -w can 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
Enter fullscreen mode Exit fullscreen mode

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)