Skip to content

Uptime & Alerting

TLS certificate expiry monitoring: never be surprised by a renewal again

Certificate expiry is the most predictable outage there is. The date is printed inside the certificate, months ahead, and yet expired certificates keep taking down websites, mail servers, VPNs and Wi-Fi logins. The cause is rarely ignorance. It is a renewal that was automated once and then failed without telling anyone.

These are the questions I get asked most often when a team sets up expiry monitoring, with the answers I give.

Our renewals are automated. Why monitor expiry at all?

Because automation fails quietly, and expiry is a deadline that does not move. An ACME client can stop renewing for many reasons: a DNS record for validation was changed, a firewall rule now blocks the validation request, the client's account key was lost in a server rebuild, the systemd timer was disabled during a migration. Each of those produces a log line on a server nobody reads.

There is a second failure that automation hides: the certificate renews on disk but the service never reloads, so the process keeps serving the old one from memory until it expires. Everything on the file system looks fine.

And the reminder emails some people relied on are gone. Let's Encrypt stopped sending expiry notification emails in June 2025. If that was your safety net, you no longer have one.

What should the check look at?

The certificate the client actually receives, fetched over the network the way a client would. Not the file on disk, not the ACME client's own status output. Check from outside the server, against the real hostname, with SNI set, on every endpoint that serves it. It is the same argument as in ping is not monitoring: test what users actually touch, the way they touch it.

"Every endpoint" matters. Behind a load balancer or DNS round robin, one node can be serving an old certificate while the others are fine. Your check will pass two times in three and look flaky. Probe each backend by address with the right SNI name, as well as the public name.

Check the whole chain, too. Intermediates expire, and old clients that trusted a root that has since expired fail in ways modern browsers do not. That is what happened at the end of September 2021, when the DST Root CA X3 certificate expired and older devices stopped trusting sites that still worked in current browsers.

Affiliate links: we may earn a commission. We also earn from qualifying purchases on eBay.

rack list

  • EcoFlowPortable power stations that keep a small rack or a closet running through longer outages than a desktop UPS covers.Try EcoFlow

How do I check one from the command line?

openssl does it in one line. This prints the expiry date of the certificate a server presents:

openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -subject -issuer -enddate

For a script, -checkend takes a number of seconds and exits with status 1 if the certificate will have expired by then. Twenty-one days is 1,814,400 seconds:

openssl s_client -connect www.example.com:443 -servername www.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -checkend 1814400 || echo "expires within 21 days"

Services that start in plain text and upgrade with STARTTLS need one more flag. For mail submission on port 587:

openssl s_client -connect mail.example.com:587 -starttls smtp -servername mail.example.com </dev/null 2>/dev/null \
  | openssl x509 -noout -enddate

Add -showcerts to the first command when you want to see every certificate in the chain the server sends.

How do I alert on it with Prometheus?

blackbox_exporter's HTTP and TCP probes record probe_ssl_earliest_cert_expiry, the expiry time, as a Unix timestamp, of the earliest-expiring certificate in the chain the server presented. Because it covers the chain, it catches an intermediate that expires before your leaf. A scrape job for a few HTTPS endpoints looks like this:

- job_name: blackbox_https
  metrics_path: /probe
  params:
    module: [http_2xx]
  static_configs:
    - targets:
        - https://www.example.com
        - https://portal.example.com
  relabel_configs:
    - source_labels: [__address__]
      target_label: __param_target
    - source_labels: [__param_target]
      target_label: instance
    - target_label: __address__
      replacement: 127.0.0.1:9115

And the alert rule:

groups:
  - name: tls
    rules:
      - alert: TLSCertificateRenewalOverdue
        expr: probe_ssl_earliest_cert_expiry - time() < 20 * 86400
        for: 1h
        labels:
          severity: ticket
        annotations:
          summary: "Certificate for {{ $labels.instance }} expires in {{ $value | humanizeDuration }}"

Add a second rule with a shorter window and a paging severity, and keep an ordinary alert on probe_success. Once a certificate has actually expired, the handshake fails, and the expiry metric can disappear along with it. An expiry alert that goes quiet at the exact moment of expiry is not one you want to rely on alone. Prometheus documents the exporter and the rule format in full.

And without Prometheus?

The Nagios-compatible check_http plugin has a certificate mode: check_http -H www.example.com -S -C 20,7 warns when fewer than 20 days remain and goes critical under 7. check_curl takes the same option. Icinga, Checkmk and Zabbix all run plugins like this or have their own certificate checks, and Uptime Kuma can warn about expiry on its HTTPS monitors. Any of them is fine. The tool matters far less than the list of endpoints you feed it.

What thresholds should I use?

Tie thresholds to how the certificate is renewed, not to a round number someone picked once. The principle: open a ticket when renewal is clearly overdue, and page only when the time left is getting close to the time a human fix takes, plus margin.

How it is renewed Renewal normally happens Ticket at Page at
ACME, 90-day certificate about 30 days before expiry 20 days 7 days
ACME, 45-day certificate about 15 days before expiry 10 days 4 days
Public CA, renewed by hand whenever someone remembers 45 days 14 days
Internal CA, renewed by hand whenever someone remembers 60 days 14 days

The manual rows have long lead times on purpose. Purchase approvals, a vendor portal, someone with the right access on holiday: a manual renewal can easily take two weeks of calendar time.

The ticket alert is the one that does the real work. If it fires and gets fixed in office hours, nobody is ever paged for a certificate. That is the goal, and it is the same principle as in writing alerts people still read: page only for what cannot wait.

Should I also monitor the renewal job?

Yes, as a heartbeat. Have the renewal job report success to your monitoring each time it runs: a push monitor, a timestamp file read by node_exporter's textfile collector, anything that alerts when the success signal stops arriving. That catches a broken renewal weeks before the expiry check would.

Then close the reload gap. With certbot, a deploy hook such as --deploy-hook "systemctl reload nginx" reloads the service after each successful renewal. As a backstop, compare the expiry of the file on disk with the one being served:

openssl x509 -noout -enddate -in /etc/letsencrypt/live/www.example.com/cert.pem

If the file on disk expires later than the certificate on the wire, the renewal worked and the reload did not.

Which certificates do people forget?

The website is never the one that expires. These are:

  • Mail: SMTP with STARTTLS on 25 and 587, IMAPS on 993.
  • LDAPS on 636, often on a directory server nobody has touched in years.
  • The RADIUS server certificate behind WPA2 or WPA3 Enterprise Wi-Fi. When it expires, laptops stop joining the network, and the symptom looks nothing like a certificate problem.
  • Management interfaces on switches, firewalls, UPS network cards, storage arrays and hypervisors.
  • VPN gateways.
  • Internal services signed by an internal CA, and the internal CA's own certificate.
  • Every backend node behind a load balancer, checked individually.

How do I find all of them?

Build the inventory from three directions. Certificate Transparency logs, defined in RFC 6962, record effectively every publicly trusted certificate, so a search for your domains lists every public certificate issued for them, including ones you did not know someone had requested. Your ACME clients' configuration lists everything they renew. And a scan of your internal ranges for TLS on the usual ports (443, 636, 993, 8443 and friends) finds the appliances. Merge the three into one list of endpoints, and feed that list to the monitor.

Lifetimes are getting shorter. What changes?

Publicly trusted TLS certificates issued since mid-March 2026 can be valid for at most 200 days. Under the CA/Browser Forum schedule, that falls to 100 days in March 2027 and to 47 days in March 2029. Let's Encrypt has announced that its default certificates will move from 90 days to 64 days in February 2027 and to 45 days in February 2028.

Two consequences. Renewal by hand stops being practical for anything public: renewing every few weeks by hand is a recipe for exactly the outage this article is about. And renewals happen more often, which means more chances for a quiet failure. The monitoring does not get harder, but it stops being optional. ACME itself is specified in RFC 8555 if you need to know what your client is doing under the hood.

What goes in the runbook?

Short, because the person reading it might be you at 3 a.m.: which host renews this certificate, how to run the renewal by hand, how to reload each service that uses it, and how to confirm from outside that the new one is being served. Runbooks an on-call engineer can use at 3 a.m. has a template that fits.

If you remember one thing: check the certificate on the wire, from outside, on every endpoint, and open a ticket long before anyone needs to be paged.

next: Uptime and SLA calculator for the numbers, the Rack list for the gear