Reported symptoms
app-api on web-01 terminates TLS itself and runs as the
unprivileged app account. Its certificate is renewed by a pipeline
that has run every ninety days for two years. The host is shared: it
carries fourteen interactive accounts and a build agent that several
teams use.
At 22:40 UTC on 26 August the renewal deployment ran and the pipeline recorded a success. At 22:52 the first customer report arrives: the service is refusing connections.
- The unit is in a failed state. Every restart attempt fails again within a second, which rules out a slow start, a port conflict or anything resource related.
- The journal entry immediately before the exit names the private key path and reports that the process could not open it. Because the message names the key file, two people immediately start looking for a corrupt or truncated key.
- The certificate deployed alongside it is current and correct, with
a
notBeforefrom that same evening. - A sibling service on the same host, using the same pipeline and its own certificate, is running normally and has not been touched.
- At 22:58, to rule permissions out, an engineer runs
chmod 644on the key. The service starts immediately. The site returns. The incident is closed at 23:05 with the cause recorded as a permissions issue.
That is where it would have ended. At 09:15 the next morning the
routine host scan flags a world readable private key under
/etc/ssl/private. The key had been readable by every account on a
shared host for ten hours and seventeen minutes, and the incident
that produced that state had been closed as resolved nine hours
earlier.
Evidence provided
$ openssl x509 -in /etc/ssl/certs/app.lab.example.crt -noout -subject -issuer -serial -datessubject=CN=app.lab.example
issuer=O=RunBook Academy Lab, CN=RunBook Lab Server Issuing CA
serial=21173B360D80F4A69A91164F1067F4F81A1B1B6E
notBefore=Aug 26 21:19:00 2026 GMT
notAfter=Nov 24 21:19:00 2026 GMTIllustrative output
$ KEY=/etc/ssl/private/app.lab.example.key
CRT=/etc/ssl/certs/app.lab.example.crt
sudo openssl pkey -in "$KEY" -pubout | openssl sha256
openssl x509 -in "$CRT" -noout -pubkey | openssl sha256SHA2-256(stdin)= 75061de3387b8969c6c33ba0931ec0e541ad4c90a4ee2ec3e734e031af66874b
SHA2-256(stdin)= 75061de3387b8969c6c33ba0931ec0e541ad4c90a4ee2ec3e734e031af66874bIllustrative output
$ systemctl status app-api.service --no-pager$ sudo journalctl -u app-api.service -n 20 --no-pager$ ls -l /etc/ssl/private/app.lab.example.key /etc/ssl/certs/app.lab.example.crt$ systemctl show app-api.service -p User -p Group$ sudo -u app test -r /etc/ssl/private/app.lab.example.key; echo $?; sudo -u app test -r /etc/ssl/certs/app.lab.example.crt; echo $?$ sudo find /etc/ssl/private -type f -perm /0044 -printf '%M %u:%g %p\n'Work the evidence before reading on
Two things went wrong nine hours apart and the second one was recorded as the fix for the first. Pull them apart before reading on.
- The journal names the key path, which is why people looked for a corrupt key. Read the message source again and say which process emitted it, and what that rules out.
- The certificate is current and the digests match. What two popular explanations does that pair of observations eliminate in one go?
ls -lshows a key that root can read and nobody else can. That is exactly what a private key should look like on most hosts. What makes it wrong on this one?- The chmod worked. Say precisely what it changed, what it did not change, and what a host with fourteen interactive accounts turns that into.
Before continuing: state what property of the key was traded for what property of the service at 22:58, and say what has to happen as a result.
Root cause
The deployment copied instead of installing
The renewal script moves the certificate and the key into place with
cp. That command carries no opinion about ownership or mode: the
resulting file is owned by whoever ran the script, and its mode
comes from that process umask. Run from an automation account with
root privileges, the key arrived owned by root with read and write
for root and nothing for anyone else.
That is a perfectly reasonable mode for a private key in general. It
is the wrong one here, because the service runs as app. The
process started, tried to open the key during TLS setup, was refused
by the kernel, and exited before binding a socket.
The sibling service was unaffected for a mundane reason: its key had been placed eight months earlier by a person, by hand, with an explicit mode and owner. The defect has been in the script all along and only appears on the ninety day boundary when a key is actually replaced.
Nothing checked whether the restart worked
The script issues a restart and does not look at the result. The pipeline therefore recorded a successful deployment at 22:40 against a service that was already down, and the outage was found twelve minutes later by a customer rather than by the system that had just caused it.
An unchecked exit status is not a small omission here. It is the difference between a deployment that rolls itself back at 22:41 and one that hands the problem to whoever is awake.
The fix removed the property that mattered
Making the key readable by everyone does start the service. The process can open the file, TLS setup completes, and the socket binds. Every observable symptom clears within seconds, which is exactly what makes the action so easy to accept at eleven at night.
What it removed is confidentiality. A private key is the entire basis on which a client believes it is talking to this service; the certificate is public and is meant to be. On a host with fourteen interactive accounts and a shared build agent, ten hours and seventeen minutes of world readability means the honest position is that the key may have left the host.
The incident was closed as a permissions issue. That description was accurate and it hid the fact that an availability incident had been converted into a key exposure.
Resolution
- Settle the exposure question before anything else, because it decides whether this ends with an
installor with a new key. Take the window from the file modification time, the journal and the shell history, then list who could have read the file during it: interactive accounts, shared build agents, backup jobs running as root, and any configuration archiving process. - On a shared host, assume the key left. A private key that could have been copied is a rotated key, and re-permissioning it afterwards restores the file mode without restoring the secrecy. This is a judgement call only where the set of principals with access was genuinely empty.
- Restore correct ownership and mode immediately regardless of the rotation decision, because that closes the window while you think. Use
sudo install -o app -g app -m 0400rather than chmod after the fact, and check the directory above the file grants no more access than the file does. - Restart the unit and confirm it stays running, then confirm from another host that the service is answering. A unit that enters a failed state twice in a row is telling you something the file listing is not.
- If exposure is credible, rotate properly: generate a new key, issue a new certificate for it, deploy both with
install, restart, and only then revoke the old certificate. Deploy before revoking, so that revocation never becomes the thing standing between you and a working service. - Be honest about what the revocation buys. In an internal PKI it works only where a CRL is genuinely distributed and checked, and for a publicly trusted certificate mainstream clients soft fail or do not check at all. The effective control for a compromised key is a short lifetime and fast reissuance, with revocation as a secondary measure rather than the plan.
- Fix the deployment path in the same change: replace
cpwithinstallcarrying an explicit owner, group and mode, write to a temporary file in the same directory and rename it into place so a partial copy is never readable, and check the exit status of the restart so a failed service can never be reported as a successful deployment. - Never leave a private key world readable while you look for the real cause, and never record a chmod as the resolution of an incident. Both are how a twenty minute outage becomes a rotation nobody planned.
- Run the same assertion across every host in the fleet before closing, because the deployment defect is shared and the other hosts have simply not reached their renewal date yet.
Verification
- Ask the running service what it is serving. From another host, take the certificate off the wire and confirm a clean verification result, and then confirm the serial is the new one rather than the old. That single observation proves the key was readable, the service started, the correct pair was deployed and the process picked it up.
- Confirm the process is genuinely new:
systemctl show app-api.service -p ExecMainStartTimestampmust report a time after the change. A service that never restarted can present a perfectly correct pair on disk while continuing to serve what it loaded hours ago. - Prove the exposure is closed rather than narrowed.
sudo -u nobody test -ragainst the key must fail, andsudo find /etc/ssl/private -type f -perm /0044must return nothing. - Run that same find on every host in the fleet, not only the one that failed. The deployment defect is shared, and a host that has not renewed recently is carrying the same script with the fault simply not yet triggered.
- If the key was rotated, confirm the old certificate is no longer present anywhere it could still be served, including load balancer configuration, container images built that week, and any backup restored into a test environment.
- Confirm the revocation actually reaches a client that checks it, rather than confirming that the serial appears on a list. An entry on a CRL nothing consults is paperwork, and it is worth knowing which of the two you have.
- Deliberately break a deployment in a staging environment and confirm the pipeline now fails rather than reporting success, since the unchecked restart is the defect most likely to reappear in a different script.
Prevention
- Place key material with a tool that takes ownership and mode as
arguments.
install -o app -g app -m 0400makes the result independent of who ran the script and of their umask. It is a one word change fromcpin nearly every deployment that gets this wrong. - Stop handing the key to the service at all. A service manager credential mechanism keeps the file root owned and unreadable by the service account, passing a copy through a private directory only that process can see. It removes the class rather than making it less frequent.
- Assert the invariant after every deployment. A search for any file under the private key directory carrying a read bit for group or other must return nothing, run as a pipeline gate and again on a fifteen minute schedule, with a file integrity watch alerting within five minutes of a mode change on those paths.
- Make the smoke test open a connection. Checking that the certificate file exists with the right dates verifies that a copy succeeded. Completing a handshake and comparing the served serial against the one just issued verifies that the service works.
- Fail the pipeline when the restart fails. A deployment step whose exit status is never examined reports success independently of reality, which is why a customer found this outage before the system that caused it did.
- Renew early enough to stay in office hours. A warning at 30 days and a page at 7 keeps certificate work away from midnight, where a chmod is much likelier to be questioned before it is accepted as a resolution.
- Use the platform course for the mechanics. Linux for Production Sysadmins - Part IV (Users) covers ownership and the service account model this rests on, Part XXX (Capabilities) covers dropping privilege without needing the key to be widely readable, and Part XXXI (Audit) covers recording who changed a file mode and when, which is the evidence this incident needed the next morning.