Skip to main content
RunBook Academy

← All break/fix scenarios in Observability

intermediatecertificate-expiry~30 min

Break/Fix: TLS Certificate Expired

Reported symptoms

  • ●From 06:07 the mobile app, the partner integration and every browser hitting `api.example.com` fail the TLS handshake with an expired-certificate error; the service itself is up and answering on plaintext health checks
  • ●The certificate file on the load balancer is valid until 2027. `openssl x509 -noout -enddate` on the file on disk shows nothing wrong
  • ●The certificate expiry dashboard shows 74 days remaining for `api.example.com`, and showed 74 days throughout the outage
  • ●Blackbox reports the endpoint healthy: `probe_success` for that target has been 1 all night and still is
  • ●The ACME renewal job has exited 0 every night for months, and it did genuinely renew this certificate 41 days ago
  • ●`portal.example.com`, which is served by the same load balancer, is completely unaffected
  • ●The graduated expiry alerts - 30, 14, 7, 3 and 1 day, plus an already-expired rung - have never fired for this host

Evidence

  • · `openssl s_client -connect api.example.com:443 -servername api.example.com` returns a certificate whose `notAfter` was 06:00 today, and whose serial does not match the serial of the file on disk
  • · `openssl x509 -in /etc/letsencrypt/live/api.example.com/fullchain.pem -noout -serial -enddate` shows a different serial and an expiry in 2027
  • · `systemctl show nginx -p ActiveEnterTimestamp` reports a start four months ago; the certificate file mtime is 41 days ago
  • · `probe_ssl_earliest_cert_expiry{job="blackbox_ssl"}` returns the identical value for all twelve targets in the job
  • · `count(count_values("e", probe_ssl_earliest_cert_expiry{job="blackbox_ssl"}))` returns 1 - twelve heterogeneous targets, one distinct expiry timestamp
  • · The blackbox module the job uses sets `tls_config.server_name: portal.example.com`
  • · Running the probe by hand against `api.example.com` reproduces it: the returned `probe_ssl_earliest_cert_expiry` is the portal certificate expiry, not the API one
  • · There is no deploy hook under `/etc/letsencrypt/renewal-hooks/deploy/`, and no reload of nginx appears in the journal for the renewal window
Diagnosis and resolutionclick to reveal

Root cause

Two independent defects met, and only the second one made this an outage. The first is the well-known renewal shape: certbot renewed the `api.example.com` certificate 41 days ago and wrote it to disk, but no deploy hook existed to reload nginx, and nginx reads its certificates at start and reload only. The worker processes have been serving the certificate they loaded four months ago, which expired at 06:00 this morning, while a perfectly valid replacement has been sitting in the filesystem the whole time. The file was never the problem and inspecting the file will never show the problem. The second defect is why nobody was warned. The blackbox module the certificate job uses sets `tls_config.server_name: portal.example.com`, copied from a module originally written for one internal endpoint and then adopted as the TLS module for the whole job. Because `server_name` drives SNI as well as hostname verification, every probe in that job negotiates with the load balancer as though it were asking for the portal, and the load balancer obligingly returns the portal certificate. `probe_ssl_earliest_cert_expiry` therefore never described `api.example.com` at all - it has been reporting the portal certificate for every one of the twelve targets since the module was adopted, which is why all twelve read the same number and why the entire graduated alert ladder, down to the already-expired rung, stayed silent through an expiry. The probe also stayed green: the portal certificate is valid, so it verified successfully against the pinned `server_name` and `probe_success` was honestly 1.

Remediation

Restore service first. `nginx -t` and then `systemctl reload nginx` makes the running workers re-read the certificate on disk, which is already valid, and fixes the outage in seconds without touching the certificate authority. Know what that reload actually does before running it: it applies the whole on-disk configuration, not only the certificate, so any edit made to `nginx.conf` in the four months since the last start goes live at the same moment. Diff the on-disk config against the version control copy first, and treat `nginx -t` as necessary rather than sufficient - it proves the config parses, not that it is the config you intended. If `nginx -t` fails, do not force it: nginx keeps running with the expired certificate, which is bad, and a failed reload leaves it exactly where it was rather than worse. Reload, not restart: a restart drops in-flight connections for every virtual host on the load balancer, including the ones that are currently working. Then close the renewal gap by installing the deploy hook so that renewal and reload are one operation, and prove it with a certbot dry run rather than by waiting sixty days. The monitoring fix is the larger piece of work and it carries a cost worth planning for: removing the pinned `server_name` makes every target in the job report its own certificate for the first time, so every certificate in the estate that is genuinely close to expiry will be discovered simultaneously and the graduated ladder will page for all of them at once. That burst is the fix working. Schedule it deliberately, with an owner for the triage and a window, rather than shipping it at 06:30 on top of a live incident.

Verification

Verify against the wire, because the wire is the only surface clients touch. `openssl s_client -connect api.example.com:443 -servername api.example.com` piped into `openssl x509 -noout -serial -enddate` must return the serial of the file on disk and the 2027 expiry; matching serials is the check that closes this, and "the browser works now" is not, because a browser that cached a session will succeed against a server that is still wrong. Confirm the other virtual hosts on the same load balancer still serve their own certificates, since the reload touched all of them. For the monitoring fix, the decisive test is distinctness: `count(count_values("e", probe_ssl_earliest_cert_expiry{job="blackbox_ssl"}))` must return a number close to the target count rather than 1, and the value for `api.example.com` must agree with what `openssl s_client` reports for the same host - compare the two numbers, do not assume. Prove the alert ladder can actually fire by temporarily raising the 30-day threshold above the current remaining days on one target and watching it trip, then putting it back; an alert that has never been observed to fire is not evidence of anything. Prove the renewal path end to end with `certbot renew --dry-run` and confirm the journal shows the reload, then confirm nginx's start or reload timestamp has actually moved.

Prevention

A probe that cannot tell its targets apart is not monitoring them, and that condition is cheap to assert. In a job of heterogeneous endpoints, the number of distinct expiry timestamps should be close to the number of targets; a job where every target reports the same value is either a spectacular coincidence or a configuration that is measuring one thing many times, and a rule on that ratio catches the whole class. Never pin `tls_config.server_name` in a module that more than one target uses - it overrides the SNI derived from the target and silently redirects the measurement, so a module with a pinned name should be named for the single endpoint it belongs to. Monitor what the server presents rather than what the filesystem holds: the useful invariant is that the serial on the wire equals the serial that was last issued, and a renewal pipeline that checks it after reloading turns the reload-forgotten failure into a pipeline error instead of an outage sixty days later. Make renewal and reload one operation through a deploy hook that validates the config before reloading and logs the result, and exercise it with a dry run on a schedule rather than trusting a job that exits 0. Keep `insecure_skip_verify: false` in production modules so that a certificate which would not validate produces a failed probe rather than a green one. And probe the certificate ladder from outside the estate as well as from inside it, because the question the monitoring is meant to answer is what a client receives, and only a client can answer that.

Reported symptoms

At 06:07 the partner integration team reports TLS failures against api.example.com. Within ten minutes the mobile app is failing for everyone, browsers are showing the expired-certificate interstitial, and the support queue is filling.

The service is up. Plaintext health checks against the backend pass. Nothing was deployed overnight.

The on-call engineer does the obvious thing and looks at the certificate:

  • The file on the load balancer is valid until 2027. openssl x509 -noout -enddate on /etc/letsencrypt/live/api.example.com/fullchain.pem shows nothing wrong at all.
  • The ACME renewal job has exited 0 every night for months, and it really did renew this certificate 41 days ago.
  • The certificate expiry dashboard says 74 days remaining. It said 74 days at 06:00, at 06:10, and it still says 74 days now.
  • Blackbox agrees: probe_success for that target has been 1 all night.
  • The graduated alert ladder - 30 days, 14, 7, 3, 1, and a rung for already-expired - has never fired for this host.
  • portal.example.com, served by the same nginx on the same box, is fine.

So the certificate is valid, the monitoring is green, the renewal worked, and clients cannot connect. Two of those four statements are true.

Evidence provided

Read-only / Safewhat the server actually presents to a client
$ openssl s_client -connect api.example.com:443 -servername api.example.com \
</dev/null 2>/dev/null | openssl x509 -noout -serial -enddate -subject
serial=03AF19C2B7E4D5601188A2C4E9F00B21
notAfter=Aug 18 06:00:00 2026 GMT
subject=CN = api.example.com

Illustrative output

Read-only / Safethe same certificate name, on disk - and a different serial
$ openssl x509 -in /etc/letsencrypt/live/api.example.com/fullchain.pem \
-noout -serial -enddate -subject
serial=04C71E88A9F2360B55D1907E3C64AA10
notAfter=Oct 16 05:59:59 2027 GMT
subject=CN = api.example.com

Illustrative output

Read-only / Safethe process is older than the file it is supposed to be serving
$ systemctl show nginx -p ActiveEnterTimestamp; stat -c '%y %n' \
/etc/letsencrypt/live/api.example.com/fullchain.pem
ActiveEnterTimestamp=Sat 2026-04-18 02:11:07 UTC
2026-07-08 03:14:52.118304127 +0000 /etc/letsencrypt/live/api.example.com/fullchain.pem

Illustrative output

Read-only / Safetwelve targets in the job, one distinct expiry timestamp between them
$ curl -s --data-urlencode 'query=count(count_values("e", probe_ssl_earliest_cert_expiry{job="blackbox_ssl"}))' \
http://prometheus:9090/api/v1/query | jq -r '.data.result[0].value[1]'
1

Illustrative output

The blackbox module that the blackbox_ssl job uses:

modules:
  http_2xx_tls:
    prober: http
    timeout: 5s
    http:
      method: GET
      preferred_ip_protocol: ip4
      ip_protocol_fallback: true
      tls_config:
        insecure_skip_verify: false
        server_name: portal.example.com

And the recording rule and the first rung of the ladder that were supposed to catch this:

- record: tls_cert_expiry_days
  expr: (probe_ssl_earliest_cert_expiry - time()) / 86400

- alert: TLSCertExpired
  expr: tls_cert_expiry_days < 0
  for: 5m
  labels:
    severity: critical

Work the evidence before reading on

Everything on that list is a true statement about something. The work is deciding which certificate each one is a true statement about.

  1. Two openssl commands, the same subject CN, two different serials. Which one is the client validating, and which one did the engineer inspect first?
  2. nginx started in April. The certificate file was written in July. What does nginx do with a certificate file between those two events?
  3. Twelve targets - an API, a portal, a partner endpoint, a status page - and one distinct expiry value between them. Under what configuration would that be the expected result rather than a coincidence?
  4. The module sets server_name. What does that field control on the wire, and which of the twelve targets does it describe?
  5. probe_success is 1 and insecure_skip_verify is false, so the probe did verify a certificate successfully. Verify it against what hostname?
  6. portal.example.com is unaffected. Is that a clue about the load balancer, or a clue about the monitoring, or both?

Before continuing: the expired rung of the ladder is tls_cert_expiry_days < 0 and it did not fire during an expiry. What number was that expression actually evaluating?

Root cause

The certificate that was renewed is not the certificate being served

nginx reads certificate files at start and at reload. It does not watch them, and it does not re-read them because the filesystem changed. The worker processes running this morning were started in April and are serving the certificate they parsed then - the one that expired at 06:00. Certbot renewed correctly in July and wrote a valid replacement, and because there is no deploy hook under /etc/letsencrypt/renewal-hooks/deploy/, nothing ever told nginx.

This is why inspecting the file was actively misleading. The file is not the service’s state; it is the service’s intended state, and the two have been out of agreement for 41 days. The serial mismatch between openssl s_client and openssl x509 -in is the whole diagnosis in one comparison, and it costs two commands.

The renewal job exiting 0 is honest. It renewed. Nobody asked it to deploy.

The probe was never looking at this certificate

tls_config.server_name sets the name the client sends in SNI and the name the certificate is verified against. The module pins it to portal.example.com, so every probe in the blackbox_ssl job - whatever target it was configured for - opens a connection announcing itself as a portal client. The load balancer selects the virtual host matching that SNI and returns the portal certificate. The probe reads the chain it was given, records the portal’s notAfter, and reports success, because the portal certificate is valid and does verify against the name it was pinned to. Every measurement in that job is correct about the portal and silent about everything else.

That is why all twelve targets read the same number, and it is the single most diagnostic fact on the list. Twelve independently managed endpoints agreeing on a certificate expiry to the second is not a coincidence; it is a measurement that has collapsed to one input.

It is also why the entire alert ladder stayed quiet. tls_cert_expiry_days for api.example.com never described api.example.com. It described a healthy portal certificate with 74 days left, so the 30-day rung was correct not to fire, and so was the already-expired rung. The rules are fine. The rules were being fed the wrong number, and a rule cannot tell.

Why the module ended up like that

The module was written for a single internal endpoint whose load balancer needed an explicit SNI, where pinning server_name was the right answer. It then became “the TLS module”, and each new target was added to the job rather than given its own module - which is the reasonable thing to do with every other blackbox module, because server_name is the only field in a TLS module that is target-specific. The defect is not a typo and it would not have looked wrong in review; it looked like reuse.

Resolution

  1. Diff the on-disk nginx configuration against version control before reloading. The reload applies everything on disk, not only the certificate, and nothing has been applied since April - so any edit made in that window goes live in the same second as the fix.
  2. Validate and reload: nginx -t && systemctl reload nginx. The workers re-read the certificate files and immediately serve the valid 2027 certificate that has been on disk since July. This is the entire outage fix and it does not involve the certificate authority.
  3. Reload, not restart. A restart drops in-flight connections for every virtual host on the load balancer, including the ones that are currently working; the reload starts new workers and lets the old ones drain.
  4. If nginx -t fails, stop and fix the config. Do not force the reload. A failed validation leaves nginx running exactly as it was - still serving the expired certificate, which is bad, but not worse - and forcing past a config error during an incident is how one outage becomes two.
  5. Confirm on the wire before telling anyone it is fixed: the serial from openssl s_client must equal the serial from openssl x509 -in on the file. A browser that succeeds may be resuming a cached session.
  6. Check the other virtual hosts on that load balancer, because the reload touched all of them. Each should still present its own certificate with its own serial.
  7. Install the deploy hook so renewal and reload become one operation: a script under /etc/letsencrypt/renewal-hooks/deploy/ that runs nginx -t, refuses to reload if validation fails, reloads on success, and logs the outcome.
  8. Prove the hook with certbot renew --dry-run rather than waiting sixty days for the next real renewal, and confirm the reload appears in the journal.
  9. Schedule the monitoring fix rather than shipping it now. Removing the pinned server_name will make every target report its own certificate for the first time, and every endpoint in the estate that is genuinely close to expiry will page at once. Give that burst a window, an owner and a triage plan.

Verification

  1. The serial on the wire equals the serial on disk. openssl s_client -connect api.example.com:443 -servername api.example.com into openssl x509 -noout -serial -enddate returns the 2027 certificate. This is the check that closes the outage; a working browser is not.
  2. Every other virtual host on the load balancer still presents its own certificate. Enumerate them and check each serial, because the reload was estate-wide on that box even though the fault was not.
  3. The probes are measuring their own targets. count(count_values("e", probe_ssl_earliest_cert_expiry{job="blackbox_ssl"})) returns a number close to the target count rather than 1.
  4. The metric agrees with the wire for a named host. Convert probe_ssl_earliest_cert_expiry for api.example.com to a date and compare it against the notAfter that openssl s_client reports for the same host. Two independent measurements of one certificate agreeing is the proof; either one alone is an assumption.
  5. The alert ladder can fire. Raise the 30-day threshold above the remaining days on one target, watch it trip, and put it back. Six rules that have never been observed to fire are six rules of unknown state.
  6. Renewal and reload are one operation. certbot renew --dry-run completes, the journal shows the hook running, and nginx's reload timestamp has moved.
  7. The distinctness rule is wired and alerts. Point it at the broken module configuration in a test instance and confirm it goes red - this is the guard that would have caught the original defect, and an unfired guard is a comment.
  8. The burst from the monitoring fix has been triaged to zero, or to a list with owners and dates. Certificates discovered to be near expiry by the corrected probe are real findings, not noise from the change.

Prevention

  • Assert that the measurement distinguishes its targets. In a job of heterogeneous endpoints the number of distinct expiry timestamps should be close to the number of targets. One distinct value across twelve is a configuration fault, and the rule that says so needs no knowledge of any certificate.
  • Never pin tls_config.server_name in a module that more than one target uses. It overrides the SNI derived from the target, so the module quietly decides which certificate every probe measures. A module with a pinned name should be named after the one endpoint it belongs to.
  • Monitor the wire, not the filesystem. The invariant that matters is that the serial the server presents equals the serial that was last issued, and a renewal pipeline that checks it after reloading converts “renewed but never loaded” from a sixty-day time bomb into a pipeline failure.
  • Make renewal and reload atomic. A deploy hook that validates the config, refuses to reload on a validation failure, and logs the result is a dozen lines, and a scheduled --dry-run proves it still works.
  • Keep insecure_skip_verify: false in production modules, so a certificate that would not validate produces a failed probe rather than a green one.
  • Probe from outside the estate as well as inside it. The question the monitoring exists to answer is what a client receives, and the only reliable answer comes from something shaped like a client.