Automation Outages and the Guardrails That Contain Them¶
Most outages caused by automation come from one of four mistakes: a bad input, the wrong target, a change applied everywhere at once, or two runs racing each other. Each has a cheap, specific guardrail in Ansible. This page walks through nine public postmortems: what happened, why it spread, and the few lines of YAML that would have stopped it or kept it small.
None of these companies was careless. Every one had automation, reviews, and experienced engineers. That's the point: the guardrails below are for the day a careful person makes an ordinary mistake.
| Incident | What went wrong | Ansible guardrail |
|---|---|---|
| AWS S3, 2017 | A mistyped input removed too many servers | assert the blast radius, then remove in serial batches |
| GitLab.com, 2017 | A wipe ran on the primary database | Check live state on the host, not just its inventory group |
| Knight Capital, 2012 | One of eight servers kept old code | any_errors_fatal and a version check across every host |
| Facebook, 2021 | The safety check had a bug | Test that guards fire, with a negative test in CI |
| CrowdStrike, 2024 | One update went to every machine at once | serial waves with max_fail_percentage: 0 |
| Atlassian, 2022 | A delete script got the wrong IDs | Look up and assert every ID before a destructive API call |
| Cloudflare, 2025 | A generated config file doubled in size | validate: and size limits on generated files |
| AWS us-east-1, 2025 | Two automation runs raced | One run at a time, and refuse stale plans |
| Datadog, 2023 | Every node patched in the same hour | Patch through serial with a health check, not a timer |
AWS S3, 2017: A Typo in an Established Playbook¶
On February 28, 2017, an Amazon S3 engineer debugging a slow billing system ran a command from an established playbook to take a small number of servers out of service. One input was entered incorrectly, and a much larger set was removed, including servers behind S3's index and placement subsystems in us-east-1. S3 in the region was unavailable from 9:37 AM until it finished recovering at 1:54 PM PST, and a large part of the internet that stored assets there broke with it.
AWS's fix was a guardrail, not a reprimand: the tool now removes capacity more slowly and refuses to take any subsystem below its minimum required capacity. Both translate directly to Ansible:
- name: Remove servers from the billing fleet
hosts: "{{ target }}"
serial: 2 # slowly, two at a time
any_errors_fatal: true # any failure stops every host
pre_tasks:
- name: Refuse to shrink the fleet below its minimum
ansible.builtin.assert:
that:
- ansible_play_hosts_all | length <= max_removals | int
- (groups['billing'] | length) - (ansible_play_hosts_all | length) >= min_in_service | int
fail_msg: >-
Refusing to remove {{ ansible_play_hosts_all | length }} hosts.
At most {{ max_removals }} may go, and {{ min_in_service }} must stay in service.
run_once: true
tasks:
- name: Drain the host
ansible.builtin.command: /usr/local/bin/fleet drain {{ inventory_hostname }}
delegate_to: localhost
changed_when: true
ansible-playbook playbooks/remove_capacity.yml -e target=billing_old -e max_removals=4 -e min_in_service=20
ansible_play_hosts_all is every host the play matched, so a mistyped target (say, billing instead of billing_old) fails the assertion before a single host is drained.
GitLab.com, 2017: The Right Command on the Wrong Server¶
On January 31, 2017, a GitLab engineer fighting a replication problem ran a command to wipe a PostgreSQL data directory, on the primary, believing it was the secondary. The backups that should have saved the day weren't working: the backup job ran pg_dump 9.2 against PostgreSQL 9.6 and failed, and the emails reporting it were rejected because DMARC wasn't set up for the cron mail. GitLab.com was down for about 18 hours, and roughly six hours of data was lost, an estimated 5,000 projects, 5,000 comments, and 700 users.
Two of GitLab's follow-ups were shell prompts that make host and environment unmistakable, and automated testing of backup restores. In a playbook, the equivalent of "look at the prompt" is to ask the host what it is, instead of trusting the inventory group it sits in:
- name: Rebuild a PostgreSQL replica
hosts: db_replicas
serial: 1
become: true
tasks:
- name: Ask PostgreSQL whether this server is a replica
ansible.builtin.command: psql -tAc "SELECT pg_is_in_recovery()"
become_user: postgres
register: in_recovery
changed_when: false
check_mode: false # read-only, so it runs during --check too
- name: Refuse to touch a primary
ansible.builtin.assert:
that: in_recovery.stdout == "t"
fail_msg: "{{ inventory_hostname }} is a PRIMARY. Not wiping its data directory."
- name: Remove the old data directory
ansible.builtin.file:
path: /var/lib/postgresql/16/main
state: absent
Inventory is just a file someone edited. A failover last week could have made db_replicas wrong; pg_is_in_recovery() can't be. The other lesson costs nothing to adopt: a scheduled playbook that restores last night's backup into a scratch database and fails loudly when it can't. A backup you've never restored is a hope, not a backup.
Knight Capital, 2012: Seven Servers Out of Eight¶
On August 1, 2012, Knight Capital deployed new trading code to the eight servers running its order router. According to the SEC's order, a technician didn't copy the new code to one of them, and no one noticed. Orders carrying a repurposed flag then woke up old, unused "Power Peg" code on that eighth server, which sent orders into the market without stopping. In about 45 minutes Knight obtained more than 4 million executions in 154 stocks; the SEC put the loss at over $460 million.
A manual, partial deploy is exactly what idempotent automation exists to prevent, but automation can leave a host behind too, if one host fails and the rest carry on. Make one failure stop everything, then prove every host matches before anything is switched on:
- name: Deploy the order router
hosts: trading
any_errors_fatal: true # a failure on one host stops the deploy everywhere
tasks:
- name: Unpack release {{ release }}
ansible.builtin.unarchive:
src: "files/router-{{ release }}.tar.gz"
dest: /opt/router
creates: "/opt/router/{{ release }}"
- name: Verify before enabling any new feature
hosts: trading
gather_facts: false
tasks:
- name: Read the release each host is actually running
ansible.builtin.slurp:
src: /opt/router/RELEASE
register: running
- name: Every host answered, and every host runs the same release
ansible.builtin.assert:
that:
- ansible_play_hosts | length == ansible_play_hosts_all | length
- >-
ansible_play_hosts
| map('extract', hostvars, ['running', 'content'])
| map('b64decode') | map('trim') | unique | list == [release]
fail_msg: "Hosts disagree on the running release. The feature flag stays off."
run_once: true
The first assertion catches the host that didn't answer (unreachable hosts drop out of ansible_play_hosts); the second catches the host that answered with the wrong version. The non-Ansible lesson is just as cheap: never reuse an old flag for a new meaning.
Facebook, 2021: The Audit Tool Had a Bug¶
On October 4, 2021, during routine maintenance, a command meant to assess the availability of global backbone capacity instead took down every connection in Facebook's backbone network. Facebook's systems were designed to audit commands like it, but a bug in the audit tool let it through. With the backbone gone, Facebook's DNS servers withdrew their BGP route advertisements, so Facebook, Instagram, and WhatsApp vanished from the internet for roughly six hours. Remote access went down with everything else, and engineers had to work inside data centers built to be hard to get into.
A guardrail you've never seen fire is a guardrail you don't know works. Test it the way you test code: with an input it must reject.
#!/usr/bin/env bash
set -u
# The capacity guard must refuse to drain the whole fleet.
# If the playbook accepts target=all, the guard is broken: fail the pipeline.
if ansible-playbook -i inventories/production playbooks/remove_capacity.yml \
-e target=all -e max_removals=4 -e min_in_service=20 --check; then
echo "Guard did not fire for target=all" >&2
exit 1
fi
echo "Guard refused target=all, as it should"
assert runs normally under --check, so the test touches nothing. The second lesson is about access: keep a way into your systems that doesn't depend on the thing you're changing, such as an out-of-band management network or a console that doesn't route through the backbone.
CrowdStrike, 2024: One Update, Every Machine¶
On July 19, 2024 at 04:09 UTC, CrowdStrike released a content update for its Falcon sensor, Channel File 291, to every online Windows machine running it. A mismatch between the update's input fields and what the sensor expected caused an out-of-bounds memory read. The sensor runs in the kernel, so Windows crashed, and kept crashing on every reboot. The update was reverted at 05:27 UTC, but Microsoft estimated 8.5 million Windows devices were affected, disrupting airlines, hospitals, and banks worldwide. CrowdStrike's review committed to a staggered deployment strategy, starting with a canary.
Ansible's version of a canary is a serial list, and the part that matters is what stops it:
- name: Roll out the endpoint agent in waves
hosts: endpoints
serial: [1, "1%", "10%", "100%"]
max_fail_percentage: 0 # one failed host stops the next wave
become: true
tasks:
- name: Install agent {{ agent_version }}
ansible.builtin.apt:
name: "endpoint-agent={{ agent_version }}"
state: present
- name: Reboot and prove the host comes back with a working agent
ansible.builtin.reboot:
test_command: systemctl is-active endpoint-agent
Without max_fail_percentage (or any_errors_fatal), serial alone doesn't stop a bad rollout: failed hosts are dropped and the next wave starts anyway. The serial batch planner shows exactly which failures stop which batch.
Atlassian, 2022: Right Script, Wrong IDs, Permanent Mode¶
On April 5, 2022, Atlassian ran a script to delete data from a retired app. The team supplied it with the IDs of entire customer cloud sites instead of the app's IDs. The script didn't check what the IDs referred to, and it ran in its "permanently delete" mode instead of "mark for deletion." Between 07:38 and 08:01 UTC it deleted 883 sites belonging to 775 customers. Restoring them took up to 14 days for some customers, because the data had to be pulled piece by piece out of backups shared with other customers. Atlassian committed to soft deletes across its systems.
If a playbook calls a destructive API, look up every ID first and assert it's what you think it is:
- name: Look up every object before deleting anything
ansible.builtin.uri:
url: "https://api.example.com/v1/objects/{{ item }}"
headers:
Authorization: "Bearer {{ api_token }}"
loop: "{{ ids_to_delete }}"
register: lookups
check_mode: false # uri is skipped in --check unless told to run
no_log: true
- name: Refuse unless every ID is an app, not a whole site
ansible.builtin.assert:
that: lookups.results | map(attribute='json.type') | unique | list == ['app']
fail_msg: "Some IDs are not apps. Nothing was deleted."
- name: Mark the apps for deletion (recoverable for 30 days)
ansible.builtin.uri:
url: "https://api.example.com/v1/objects/{{ item }}"
method: DELETE
headers:
Authorization: "Bearer {{ api_token }}"
status_code: [200, 202, 204]
loop: "{{ ids_to_delete }}"
no_log: true
Make the recoverable path the default, and make the permanent one require an explicit -e flag that a pasted command won't carry by accident. More uri safety details are in URI and API Automation.
Cloudflare, 2025: A Generated File Doubled in Size¶
On November 18, 2025, a ClickHouse permissions change made the query that builds Cloudflare's Bot Management "feature file" return duplicate rows. The file doubled in size, past the limit of 200 features that Cloudflare's proxy preallocates memory for, and the proxy panicked. The file was regenerated every five minutes, and only some database nodes had the change, so the network flapped between working and failing before failing everywhere. Core traffic was largely flowing again by 14:30 UTC, and everything was back by 17:06. Cloudflare's first remediation: treat configuration files it generates with the same suspicion as user input.
Ansible renders configuration from data all day long. Check the data, then let the service check the file before it replaces the live one:
- name: Refuse a feature list larger than the proxy can load
ansible.builtin.assert:
that: bot_features | length <= 200
fail_msg: "{{ bot_features | length }} features; the proxy preallocates room for 200."
- name: Write the feature file only if the proxy accepts it
ansible.builtin.template:
src: features.json.j2
dest: /etc/proxy/features.json
validate: /usr/local/bin/proxy --check-features %s
notify: Reload proxy
validate: runs against a temporary copy (%s) and leaves the live file untouched if the command fails. Pair it with serial on the reload, so a file that passes validation but still misbehaves reaches one batch, not the whole fleet. More on templates and validation.
AWS us-east-1, 2025: Two Automation Runs Raced¶
DynamoDB's DNS records are maintained by automation: a DNS Planner produces plans, and independent DNS Enactors apply them through Route 53. On October 19–20, 2025, one Enactor was badly delayed while applying an older plan. Another Enactor applied a newer plan, then ran its cleanup, which deletes plans that are much older than the newest one. The delayed Enactor's old plan landed on the regional endpoint, overwriting the newer one, and the cleanup then deleted that old plan, leaving dynamodb.us-east-1.amazonaws.com with an empty DNS record. The inconsistent state also blocked later updates and needed manual repair. The event ran from 11:48 PM PDT on October 19 to 2:20 PM PDT on October 20 and spread into EC2 and load balancers. AWS disabled the automation worldwide until the race was fixed.
Two runs of the same automation acting on the same state is a race, even when each run is correct on its own. Make runs take turns, and make each run refuse to apply something older than what's live:
# cron or CI: one run at a time; the next one waits instead of racing
flock --exclusive --timeout 600 /var/lock/dns-sync.lock \
ansible-playbook playbooks/dns_sync.yml
- name: Read the generation that's live right now
ansible.builtin.uri:
url: "https://dns-api.internal/v1/zones/{{ zone }}"
register: live
check_mode: false
- name: Refuse to apply a plan older than the live one
ansible.builtin.assert:
that: plan.generation | int > live.json.generation | int
fail_msg: "Plan {{ plan.generation }} is older than live {{ live.json.generation }}; another run got there first."
In AWX and Ansible Automation Platform, a job template runs one job at a time unless Concurrent Jobs is enabled. Leave it off for anything that writes shared state, and remember that job slicing runs its slices concurrently even when the box is unchecked.
Datadog, 2023: Every Node Patched in the Same Hour¶
On March 8, 2023, Datadog's Ubuntu 22.04 nodes installed a systemd security update. Ubuntu's default unattended upgrades were still active on them, so every host upgraded in the same window, between 06:00 and 07:00 UTC. Installing the update restarted systemd-networkd, and since systemd v248, networkd deletes routing rules it doesn't manage when it starts, including the ones Cilium had installed for container networking. More than 60% of Datadog's instances dropped off the network across several regions and three cloud providers. Freshly booted nodes were fine, which is why nothing had caught it: the problem only appeared when networkd restarted on a node that was already running.
A security patch is a change like any other. Roll it out like one:
- name: Patch Kubernetes nodes a few at a time
hosts: k8s_nodes
serial: "10%"
max_fail_percentage: 0
become: true
tasks:
- name: Apply pending updates
ansible.builtin.apt:
upgrade: safe
update_cache: true
- name: The routing rules container networking needs must survive
ansible.builtin.command: ip rule show
register: rules
changed_when: false
failed_when: "'lookup local' not in rules.stdout"
Then stop the timer from doing it behind your back: set APT::Periodic::Unattended-Upgrade "0"; on nodes that Ansible patches. The Kubernetes side of this incident, including the systemd-networkd settings that leave CNI routes alone, is in Kubernetes Outages.
What the Nine Have in Common¶
| Failure mode | Incidents | The habit that contains it |
|---|---|---|
| Bad input | AWS S3, Atlassian, Cloudflare | Validate inputs and generated data with assert and validate: before acting |
| Wrong target | GitLab.com, Knight Capital | Ask the host what it is; verify every host after the change |
| Everything at once | CrowdStrike, Datadog | serial waves that stop on the first failure |
| Two runs racing | AWS us-east-1 | One run at a time; refuse plans older than what's live |
| Broken safety net | Facebook, GitLab.com | Test guards with inputs they must reject, and test restores |
Every guardrail here is under ten lines of YAML. The expensive part is remembering to add them before you need them, which is why they belong in roles and templates your team copies, not in a postmortem.
Check Yourself¶
A canary that doesn't stop
A play has serial: [1, "10%", "100%"] and nothing else about failures. The canary host fails its health check. What happens next?
- The play stops: the canary failed
- The failed host is dropped and the
10%batch starts anyway - Ansible retries the canary until it passes
serial only sets batch sizes. A failed host is removed from the play and the next batch runs. Only a batch in which every host fails ends the play. Add max_fail_percentage: 0 or any_errors_fatal: true to make the canary actually gate the rollout.
Inventory said replica
A playbook targets db_replicas and wipes the data directory. Yesterday a failover promoted one of those replicas to primary, but the inventory wasn't updated. Which guard stops the wipe?
-
hosts: db_replicasalready guarantees the host is a replica -
--checkbefore the real run - Asking PostgreSQL with
pg_is_in_recovery()and asserting the answer ist
The inventory group is only as accurate as the last person who edited it. Live state on the host is the source of truth, so check it right before the destructive step.
When validate runs
A template task has validate: nginx -t -c %s. The rendered file is invalid. What's on disk afterwards?
- The old file, unchanged: validation ran on a temporary copy and failed
- The new file, followed by a failed task
- Nothing: Ansible deletes the destination when validation fails
validate checks the temporary file passed in as %s. Only if the command succeeds does Ansible move it into place, so a bad render never reaches the live path.
Sources¶
- AWS: Summary of the Amazon S3 Service Disruption (February 2017)
- GitLab: Postmortem of database outage of January 31
- SEC: Order against Knight Capital Americas LLC (October 2013)
- Meta Engineering: More details about the October 4 outage
- CrowdStrike: Channel File 291 root cause analysis; Microsoft: Helping our customers through the CrowdStrike outage
- Atlassian: Post-incident review on the April 2022 outage
- Cloudflare: Cloudflare outage on November 18, 2025
- AWS: Summary of the Amazon DynamoDB Service Disruption in us-east-1 (October 2025)
- Datadog: 2023-03-08 incident: a deep dive into the platform-level impact
Related¶
- Forks, Serial, Strategy, and Throttle — batch sizes and what stops a rollout
- Check Mode and Diff Mode — what a dry run can and can't tell you
- Multi-Environment Inventory — a preflight play that refuses production without confirmation
- Kubernetes Outages: 11 Real Postmortems
Next¶
Continue to Troubleshooting.