Skip to main content
RunBook Academy

← All break/fix scenarios in Ansible

advancedplaybook~35 min

Break/Fix: the new TLS configuration is on disk on every host and no host is serving it

Reported symptoms

  • A TLS cipher policy change was deployed three days ago and the fleet is still negotiating the old ciphers
  • `/etc/nginx/conf.d/tls.conf` on every host contains the new policy, byte for byte as intended
  • The change was deployed twice: the first run failed on an unrelated task, the second run was completely green
  • The second run reported `changed=0` for the TLS task, which the team read as confirmation that the change was already applied
  • Restarting nginx by hand on one host immediately fixes that host
  • The handler is present, correctly named, and identical to handlers that work elsewhere in the same role

Evidence

  • · `openssl s_client -connect <host>:443 -cipher ...` negotiates a cipher the new policy forbids
  • · `nginx -T` on the host shows the running configuration still contains the old cipher list
  • · `stat -c %Y /etc/nginx/conf.d/tls.conf` shows the file was written three days ago
  • · `systemctl show nginx -p ActiveEnterTimestamp` shows the process started weeks before that
  • · The first run log shows `changed` on the TLS task, then a failure on a later task, and no `RUNNING HANDLER` section at all
  • · The second run log shows `ok` on the TLS task, no notification, and no `RUNNING HANDLER` section
  • · `ansible-playbook ... --check --diff` now reports no change, because the file on disk is already correct
Diagnosis and resolutionclick to reveal

Root cause

Handlers notified during a play are queued and flushed at the end of the play. If the host fails before the flush point, the queue for that host is discarded and the handler never runs. In the first deployment the TLS task wrote the file and notified the reload, a later and unrelated task failed on every host, and the play ended with the reload still sitting in the queue. The second deployment is where the fault became permanent rather than transient: by then the file on disk was already correct, so the task reported `ok`, and a task that reports `ok` does not notify. There was nothing left to trigger the reload, and no run after that could ever trigger it, because every subsequent run finds the file correct too. The fleet is now in the one state that no amount of re-running will repair - the desired configuration on disk, the old configuration in memory, and a green run confirming that everything matches. The team read `changed=0` as evidence of correctness, which is exactly what it is not: it is evidence that Ansible has nothing to do, and Ansible has no view of what the running process loaded.

Remediation

The immediate repair is to reload the service on every affected host, in a controlled way, since the configuration is already in place and only the running process is stale. Do it as a deliberate, rate-limited play with a health check between batches rather than a fleet-wide restart, because 200 simultaneous reloads of a TLS-terminating tier is its own outage. The durable fix is to stop depending on the queue surviving: run the deployment with `--force-handlers` or set `force_handlers: true` on the play so a later failure cannot discard a pending reload, place a `meta: flush_handlers` after the configuration block so the reload happens before anything that can fail, and add a verification task that asserts the service is serving the new policy rather than that the file exists.

Verification

Assert the negotiated cipher from a client, not the file on disk - the whole incident is a case of the file being right and the service being wrong, so any check that reads the file reproduces the original mistake. `nginx -T` on the host must show the new policy in the running configuration, and `systemctl show nginx -p ActiveEnterTimestamp` must show a start or reload more recent than the file. Prove the guard can fail: on one test host, write the file, suppress the reload, and confirm the verification task fails the run. Re-run the whole play afterwards and require `changed=0` and a passing verification together, since either one alone is satisfied by the broken state.

Prevention

Treat a handler as a promise that can be lost, because it can. Set `force_handlers: true` on any play whose handlers restart or reload something that matters, and flush explicitly after the configuration block instead of relying on end-of-play timing. Never accept `changed=0` as evidence that a service is running the configuration you deployed: it means the file matches, which is a different claim. Every play that reloads a service should end with an assertion about the service, not about the file - a negotiated cipher, a version endpoint, a header, a `systemctl show` timestamp compared against the file mtime. And when a run fails partway, record which hosts had notified but unflushed handlers before deciding whether a rerun is safe, because a rerun is precisely what converts this fault from recoverable to invisible.

Reported symptoms

A compliance scan flags the entire web tier for offering a cipher the organisation retired three days ago. The change to retire it was deployed three days ago.

The deployment history is short and, on the face of it, reassuring:

  • Tuesday 14:10 - the play ran, wrote the new TLS configuration, and then failed on a later task that manages a logrotate fragment. 200 hosts failed.
  • Tuesday 14:40 - the logrotate task was fixed and the play was run again. Completely green. failed=0. The TLS task reported ok.
  • Wednesday and Thursday - the nightly converge ran green both nights.

The file on every host contains the new cipher policy. The team read ok on the TLS task as “already correct, nothing to do”, which is what it means.

Restarting nginx by hand on one host fixes that host instantly, which made the first hypothesis a caching problem at the load balancer.

Evidence provided

Read-only / Safea cipher the new policy forbids, still negotiated
$ openssl s_client -connect web01.example.com:443 -tls1_2 -cipher 'ECDHE-RSA-AES128-SHA' </dev/null 2>/dev/null | grep -m1 Cipher
    Cipher    : ECDHE-RSA-AES128-SHA
Read-only / Safethe file was written on Tuesday
$ ansible web01 -i inventory -b -m ansible.builtin.command -a 'stat -c %y /etc/nginx/conf.d/tls.conf'
2026-08-08 14:11:52.318 +0000
Read-only / Safethe process has been running since July
$ ansible web01 -i inventory -b -m ansible.builtin.command -a 'systemctl show nginx -p ActiveEnterTimestamp'
ActiveEnterTimestamp=Mon 2026-07-20 03:14:07 UTC
Read-only / Safethe first run - notice what is absent
$ grep -E 'TASK|RUNNING HANDLER|changed:|ok:|fatal:' logs/tuesday-1410.log | head -12
TASK [nginx : Deploy the TLS cipher policy] ************************************
changed: [web01]
TASK [logging : Install the logrotate fragment] ********************************
fatal: [web01]: FAILED! => {"msg": "Destination directory /etc/logrotate.d/vendor does not exist"}

PLAY RECAP *********************************************************************
web01                      : ok=14   changed=1    unreachable=0    failed=1
Read-only / Safethe second run - green, and the reload is still absent
$ grep -E 'TASK|RUNNING HANDLER|changed:|ok:' logs/tuesday-1440.log | head -12
TASK [nginx : Deploy the TLS cipher policy] ************************************
ok: [web01]
TASK [logging : Install the logrotate fragment] ********************************
changed: [web01]

PLAY RECAP *********************************************************************
web01                      : ok=16   changed=1    unreachable=0    failed=0

Work the evidence before reading on

The handler is fine. It is the same handler that reloads nginx successfully in a dozen other plays.

  1. Search both run logs for the string RUNNING HANDLER. How many times does it appear across the two runs combined?
  2. In the first run the TLS task reported changed and the play then failed. What happens to a notification that has been queued but not yet flushed?
  3. In the second run the TLS task reported ok. What does a task that reports ok notify?

Before continuing: is there any future run of this playbook, with no further edits, that would reload nginx? Justify the answer from how notify is triggered.

Root cause

1. Handlers are queued, and the queue does not survive a failure

notify does not run anything. It marks a handler as pending for that host. Pending handlers run at the flush point, which by default is the end of the play.

When a host fails a task, Ansible stops processing that host and its pending handler queue is discarded. Nothing is logged about the discarded handlers, and nothing in the recap distinguishes a host that failed with an empty queue from a host that failed with a reload waiting in it.

The first run wrote the file, queued the reload, and then failed on the logrotate task. The reload was dropped on all 200 hosts.

2. The second run made it permanent

This is the part that turns a recoverable situation into an invisible one.

By 14:40 the file on disk was already correct - the first run wrote it before failing. So the TLS task compared the intended content against the file, found them identical, and reported ok.

A task that reports ok does not notify. There was no notification to queue, no handler to flush, and a perfectly green run.

Every run since has done the same thing, and every run in the future will, because the file will keep matching. The system has settled into a stable state that no rerun can leave:

IntendedActual
File on disknew policynew policy
Running processnew policyold policy
Ansible reportokok

Ansible is not wrong. It manages files, and the file is correct. It has no visibility into what the running process loaded, and never claimed any.

3. changed=0 was read as a success signal

The team’s reading - “the TLS task says ok, so the change is applied” - is the most natural reading available and it is the wrong one. changed=0 means the described state matches the observed state for everything Ansible describes. Whether a daemon has re-read a file it opened weeks ago is not something Ansible describes.

Resolution

  1. Confirm the fault on more than one host before acting. Compare the file mtime against the service start timestamp on a sample; where the file is newer, the process has not read it.
  2. Reload the service on the affected hosts as a deliberate, rate-limited operation. The configuration is already in place, so this is a reload rather than a deployment - but 200 simultaneous reloads of a TLS-terminating tier is an outage of its own, so batch it.
  3. Verify from a client between batches. Negotiate against a repaired host and confirm the retired cipher is refused; the file has been correct for three days and proves nothing.
  4. Add force_handlers: true to the play, so a later failure can no longer discard a pending reload.
  5. Add an explicit meta: flush_handlers immediately after the configuration block, so the reload happens before any task that could fail rather than at the end of the play.
  6. Add a verification task that asserts the service is serving the intended policy. This is the task that would have failed the green run on Tuesday, and its absence is the reason nobody noticed.
  7. Write down, in the runbook for this play, what to check after a run that failed partway: which tasks reported changed, which of those carry a notify, and therefore which hosts have unflushed handlers.

Verification

  1. A client cannot negotiate the retired cipher. openssl s_client against a repaired host fails for the forbidden cipher and succeeds for a permitted one. This is the only check that is independent of both the file and Ansible.
  2. The running configuration matches the file. nginx -T on the host shows the new policy; nginx -T renders what the process loaded, unlike reading the file.
  3. The service is younger than the file. systemctl show nginx -p ActiveEnterTimestamp is more recent than the file mtime on every repaired host. This comparison is cheap, scriptable across the fleet, and would have caught the fault on Tuesday evening.
  4. The guard can fail. On a test host, write a modified policy and suppress the reload, then run the play: the verification task must fail. A verification that has only ever passed is untested.
  5. A clean rerun passes both conditions together. The play reports changed=0 and the verification task passes in the same run - the broken state satisfies the first condition alone, which is exactly why one is not enough.
  6. Handlers survive a failure now. Reintroduce a deliberately failing task after the TLS block on a test host and confirm the reload still runs, thanks to force_handlers.
  7. The whole fleet is covered. The file-newer-than-service comparison returns nothing across the estate, not just across the hosts you repaired first.

Prevention

  • Set force_handlers: true on any play whose handlers restart or reload something that matters. The default behaviour is a reasonable safety choice for handlers that should not run on a half-configured host, and a poor one for a reload that was already earned.
  • Flush explicitly. meta: flush_handlers after the configuration block puts the reload where you can reason about it, instead of at the end of a play that may not get there.
  • End every service-affecting play with an assertion about the service. A negotiated cipher, a version endpoint, a header, a systemctl show timestamp - anything that can only be true if the daemon re-read the file.
  • Never treat changed=0 as proof of correctness. It says the files match. It says nothing about what any running process has loaded.
  • After any run that fails partway, enumerate the hosts with notified but unflushed handlers before rerunning. That information exists only in the failed run log and the rerun erases it.
  • Compare service start time against configuration file mtime as a fleet-wide drift check. It is one command per host and it finds this entire class of fault, including the cases where a human edited a file and forgot to reload.