Reported symptoms
The payments API runs on two origin nodes behind a shared edge tier.
The edge terminates public TLS, re-encrypts, and forwards to nginx on
web-01 and web-02 across the internal network. It has been quiet
since a migration in February.
At 02:00 UTC on 26 August it stopped. The incident channel filled up faster than the facts did:
- 02:00:04, the edge begins answering every request for
api.example.comwith502. Error rate goes from zero to one hundred per cent in under ten seconds. - The origins look healthy.
nginxis active on both, no process has restarted, and the access log holds a normal request at 01:59:58 and nothing at all after it. - 02:06, the on-call declares a bad deploy and starts a rollback. The last release shipped six days earlier. The rollback finishes at 02:19 and changes nothing.
- The marketing site and the asset host, both behind the same edge tier, keep serving. So does the internal admin console, which is reached directly on the management network over plain HTTP.
- 02:05, somebody opens the certificate dashboard. It shows 63 days
remaining for
api.example.com. Certificates are ruled out, and stay ruled out for the next twenty minutes. - 02:11, the nightly renewal timer is checked. It ran at 00:17 and exited zero. That is taken as confirmation.
At 02:22 both origins are restarted on the theory that something is stuck. Nothing changes. The team now has a healthy application, a green renewal timer, a green expiry dashboard, and an API that has been completely unavailable for twenty-two minutes.
Evidence provided
$ openssl x509 -in /etc/nginx/tls/api.example.com.pem -noout -subject -issuer -serial -datessubject=CN=api.example.com
issuer=O=Example Internal PKI, CN=Example Internal Issuing CA
serial=6C1F9A47D0B33E82A5C41E7F09B6D2483AF15C90
notBefore=Feb 26 02:00:00 2026 GMT
notAfter=Aug 26 02:00:00 2026 GMTIllustrative output
$ openssl x509 -in /etc/nginx/tls/api.example.com.pem -noout -checkend 0; echo exit $?Certificate will expire
exit 1Illustrative output
$ openssl x509 -in /etc/edge/tls/public-api.pem -noout -checkend 0; echo exit $?Certificate will not expire
exit 0Illustrative output
$ openssl s_client -connect 192.0.2.11:443 -servername api.example.com </dev/null 2>/dev/null | openssl x509 -noout -serial -datesserial=6C1F9A47D0B33E82A5C41E7F09B6D2483AF15C90
notBefore=Feb 26 02:00:00 2026 GMT
notAfter=Aug 26 02:00:00 2026 GMTIllustrative output
$ sudo certbot certificatesWork the evidence before reading on
Finding the expiry took one command. The question worth sitting with is how six months of automation and monitoring managed to say nothing about it.
- The dashboard and the certificate file disagree by 63 days, and both are reporting honestly. What is each of them measuring, and which one does the edge actually depend on?
- The renewal timer exited zero at 00:17, one hour and forty-three minutes before the expiry. State precisely what that exit status proves and what it cannot prove.
- The certificate on disk and the certificate on the wire carry the same serial. Which hypothesis does that eliminate, and what would you have concluded instead if they had differed?
- Every other origin in the estate serves its certificate through a symlink. This one serves a file. What does that single difference tell you about who was responsible for renewing it?
Before continuing: say what a renewal timer exiting zero actually proves, and name the one observation that would have revealed months earlier that this certificate had no owner.
Root cause
The expiry itself, and why the edge turned it into a 502
The certificate stopped being valid at 02:00:00 UTC. The edge does not merely connect to the origin, it verifies the origin certificate before forwarding, so from that second onward every handshake between the edge and both origins failed and every request became a 502. The application never saw any of it, which is exactly why the process metrics stayed clean and the access log simply stopped.
The evidence that settles this is the pair of -checkend 0 results.
The origin certificate reports that it will expire and exits 1. The
edge certificate, asked the identical question, reports that it will
not expire and exits 0. Two certificates, one question, opposite
answers, and only one of them was ever on a dashboard.
Nothing owned the renewal, so nothing renewed
The certificate was copied onto the rebuilt origins by hand in
February. The renewal client was installed but was never asked to
issue anything, so it has no configuration for this name, and
certbot certificates reports an empty inventory. The nightly timer
ran, found no renewal configuration, and exited zero, which is the
correct behaviour for a tool with nothing to do.
That is what makes this failure mode so durable. Every observable signal was green, and each one was green for an honest reason. The timer really did run. The application really was healthy. The only thing nobody asked was whether any of that automation had ever been pointed at this particular file.
The expiry probe was watching a different certificate
The probe target list has three entries and all three are public URLs.
# tls-probe targets, as they stood at 02:00
targets:
- https://api.example.com/healthz
- https://www.example.com/
- https://assets.example.com/
Every one of those names resolves to the edge, and the edge answers all of them with its own certificate. The 63 days on the dashboard was a true statement about the edge certificate and told nobody anything about the origin. A probe that measures the front door cannot see a certificate that lives one hop further in.
Resolution
- Confirm the clock on both origins before replacing anything, with
date -uand the NTP synchronisation state. A fast clock rejects a valid certificate and produces symptoms identical to expiry, and renewing a certificate to fix a clock costs the whole change window. - Read the certificate off the origin listener from a third host, not from the filesystem, and record its serial and notAfter. This is the fact the rest of the work will be measured against, and it is the fact the dashboard never held.
- Confirm the private key on web-01 still corresponds to the certificate before you touch either. Compare the SHA-256 digest of the public key derived from the key file with the digest of the public key extracted from the certificate. If they differ you have a second incident and enrolment must generate a new key pair.
- Take web-02 out of the load balancer pool so that web-01 can be repaired and proven without a half-fixed estate serving traffic. With the API already at one hundred per cent errors there is no availability left to protect, but there is still a rollback to protect.
- Enrol web-01 with the internal ACME endpoint under the name
api.example.com, rather than issuing another certificate by hand. The point of this step is not the certificate, it is that something now owns the renewal. - Point nginx at the full chain the client publishes and reload rather than restart, so that established connections drain. Confirm the reload was accepted before moving on.
- Verify web-01 from outside the host, then put it back in the pool and take web-02 out. Repeat the enrolment and reload there. Never renew both origins in the same step: a mistake made twice is not diagnosable.
- Add both origin addresses to the expiry probe with the correct server name before the incident is closed, because the control that failed is the one most likely to be forgotten once the pages stop.
- Do not disable origin verification at the edge, and do not install a self-signed or backdated certificate as a stopgap. The first removes authentication between the edge and the origin permanently, and the second adds a trust failure on top of the expiry failure.
Verification
- From a host that is neither origin nor the edge, open a TLS connection to each origin address with
api.example.comsent as the server name and read the presented certificate. The serial must differ from6C1F9A47D0B33E82A5C41E7F09B6D2483AF15C90and the notAfter must be months away. - The client must report a verification result of zero against that connection, which is the check that was failing and the one that
-checkendalone cannot answer. - Run
openssl x509 -checkend 2592000against the certificate read off the wire and confirm it reports that the certificate will not expire, so the new material clears a thirty-day horizon. - Request the API health endpoint through the edge from outside the estate, with verification enabled, and confirm the normal response body rather than a 502.
- Watch the edge 502 rate across at least one full monitoring interval and confirm it is zero and stays zero, rather than reading a single successful request as proof.
- Run the renewal client inventory on both origins and confirm
api.example.comis now listed with its expiry, which is the difference between a renewed certificate and a renewable one. - Confirm nginx is reading the path the renewal client publishes, not a copy of it, so that the next renewal reaches the server without anybody intervening.
- Confirm the origin addresses now appear as expiry probe targets and that the resulting metric reports the new notAfter, so the monitoring is measuring the certificate that can actually take the API down.
Prevention
- Probe every TLS listener, including the ones behind the edge. A check against the origin address with the correct server name, warning at 30 days and paging at 7, is the single control that turns this incident into a ticket. The public probe was never capable of seeing the fault.
- Alert on unchanged expiry, not on timer success. A renewal that exits zero proves the tool ran. The actionable signal is a notAfter that has not advanced inside the renewal window, and it catches orphans, broken deploy hooks and mis-scoped configuration with one rule.
- Make ownership a testable property. An inventory job that walks every certificate a host serves and fails on any file that no renewal configuration claims would have flagged these two origins in February, on the day they were built.
- Rebuild by re-enrolling, never by copying. Copying certificate and key material off a retired host is fast, invisible, and produces exactly the orphan that caused this. Make it an exception that needs a second engineer to approve.
- Generate the certificate inventory from the hosts. A hand-maintained spreadsheet records what somebody believed was deployed. A generated inventory records what is deployed, and the difference between those two is where outages live.
- Choose the hour your certificates expire in. Issuance time determines expiry time, and a notAfter at 02:00 guarantees that the failure, if it comes, arrives with the smallest possible number of people awake to meet it.