The outage usually starts with a ticket that says “the API is down” and ends with someone finding a certificate that expired at 02:00. Nobody forgot to renew it on purpose. The renewal job failed silently three weeks ago, or the cert was renewed on disk but the load balancer kept serving the old one, or it was a vendor-managed endpoint that nobody owned. A calendar reminder does not catch any of those cases. You need to check what the endpoint actually serves, from the outside, and alert well before the deadline.
The Prometheus blackbox exporter does this well, and most teams that run Prometheus already have it deployed for HTTP checks. They just never write the expiry alerts.
Probe what clients see, not what is on disk
Checking the PEM file on the server tells you what you think is deployed. Probing the endpoint tells you what clients receive. Those differ more often than you would expect: a reverse proxy that was never reloaded, a CDN with its own certificate, a second node in a pool that missed the deploy. The blackbox exporter does a real TLS handshake and exposes probe_ssl_earliest_cert_expiry, a Unix timestamp for the earliest expiry in the presented chain. Because it looks at the whole chain, it also catches an expiring intermediate.
A minimal module in blackbox.yml:
modules:
tls_connect:
prober: tcp
timeout: 5s
tcp:
tls: true
tls_config:
insecure_skip_verify: false
https_2xx:
prober: http
timeout: 5s
http:
method: GET
valid_status_codes: []
fail_if_not_ssl: true
Use tls_connect for anything that is not HTTP: LDAPS on 636, SMTP submission with implicit TLS on 465, internal gRPC, the management interface of a firewall. Use https_2xx when you also want to know the service answers.
Scrape config with a target list you can review
Keep the targets in a file so additions go through code review like everything else:
scrape_configs:
- job_name: tls-expiry
metrics_path: /probe
params:
module: [tls_connect]
scrape_interval: 5m
file_sd_configs:
- files: ["/etc/prometheus/targets/tls_endpoints.yml"]
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
- targets:
- api.example.internal:443
- ldap01.example.internal:636
- vpn.example.com:443
labels:
owner: platform
The owner label matters more than it looks. When the alert fires, the routing decides who gets it, and “unknown owner” is exactly how certificates expire.
Five minutes is plenty for a scrape interval. Certificates do not change every second, and you will be probing endpoints you do not own.
Alert in stages
One alert at seven days is too late for anything that needs a purchase order or a change window. Two stages work better: a ticket at 21 days, a page at 7.
groups:
- name: tls-expiry
rules:
- alert: TLSCertExpiringSoon
expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 21
for: 1h
labels:
severity: ticket
annotations:
summary: " certificate expires in days"
- alert: TLSCertExpiringCritical
expr: (probe_ssl_earliest_cert_expiry - time()) / 86400 < 7
for: 15m
labels:
severity: page
- alert: TLSProbeFailing
expr: probe_success{job="tls-expiry"} == 0
for: 30m
labels:
severity: ticket
The third rule is the one people skip. If the handshake fails, probe_ssl_earliest_cert_expiry is not exported for that probe, and the expiry alerts simply stop evaluating for that target. A host that already serves an expired certificate fails verification, the metric vanishes, and the expiry alert goes quiet exactly when it should be loudest. probe_success == 0 covers that gap.
With short-lived ACME certificates, 21 days may be noisy if your renewal runs at 30 days remaining. That is fine: the alert then means “renewal should have happened and did not”, which is the failure you care about. Adjust the threshold to sit just below your renewal point, not to a round number.
Check it by hand
Before trusting the alert, confirm the exporter sees what you expect:
curl -s "http://127.0.0.1:9115/probe?module=tls_connect&target=ldap01.example.internal:636" \
| grep -E 'probe_success|probe_ssl_earliest_cert_expiry'
# compare against openssl
echo | openssl s_client -connect ldap01.example.internal:636 -servername ldap01.example.internal 2>/dev/null \
| openssl x509 -noout -enddate
If the two disagree, you are probably hitting a different node behind a load balancer or missing SNI. For virtual hosts on a shared IP, set server_name in the module’s tls_config, or the exporter may receive the default certificate instead of the one users get.
Things that still bite
- Internal CAs: mount the CA bundle and set
ca_fileinstead of switching verification off. Skipping verification hides chain problems. - Endpoints behind a firewall: the exporter has to run where the clients are. A probe from the monitoring VLAN says nothing about what partners see over the internet.
- Inventory drift: the target list goes stale. Once a quarter, diff it against your load balancer VIPs and DNS records.
Why this matters
Every expired-certificate outage I have seen had a renewal process. What was missing was a check from the client’s side and an owner on the alert. The blackbox exporter is usually already running; adding a module, a target file and three rules takes an afternoon. That is cheap compared with explaining why an expired LDAPS certificate locked everyone out of the VPN on a Monday morning.