Runbook: Rotate the fleet automation SSH credentials
1 · Prerequisites
Confirm every item is in place before any state change.
2 · Pre-checks
Read-only diagnostic commands. If any of these don't match expected output, stop and investigate further.
- · The complete list of hosts that trust the current automation key is known, including hosts outside the primary inventory
- · Every host in that list is currently reachable, or the unreachable ones are listed by name before the rotation starts
- · A break-glass access path exists that does not depend on the automation key - console, a second key, or an operator account
- · The reason for rotation is recorded: scheduled, staff change, or suspected compromise (which changes the ordering)
- · The new keypair has been generated on the controller and its private half has never left it
- · A change window exists, and the removal step is scheduled for a time when someone can respond to a lockout
3 · Procedure
Execute each step in order. Verify the expected output of a step before moving to the next.
- 1Generate the new keypair on the controller with a distinguishing comment
- 2Snapshot the current trust state: which hosts have the old key, by fingerprint
- 3Add the new public key to every host ALONGSIDE the old one - do not use exclusive mode
- 4Verify the new key works on every host, using the new key explicitly and nothing else
- 5Record the hosts where the add or the verify failed - these are now a tracked exception list, not a footnote
- 6Cut the controller over to the new key: update ansible.cfg or the inventory connection variables
- 7Run a full read-only pass on the new key to confirm the whole fleet is reachable with it
- 8Remove the old public key from every host, in waves, with verification between waves
- 9Prove the old key no longer authenticates anywhere, by attempting it and requiring failure
- 10Destroy the old private key on the controller and in any backup that holds it
- 11Close out the exception list: every host that missed a wave is either done or formally quarantined
4 · Verification
Confirm the procedure actually fixed the problem.
- ✓Every host authenticates with the new key: a run using only the new identity file returns SUCCESS fleet-wide
- ✓The new key fingerprint appears in the automation authorized_keys on every host in the list
- ✓The old key fingerprint appears on NO host - verified by fingerprinting the file, not by grepping for the fingerprint string
- ✓An explicit attempt to authenticate with the old key fails with Permission denied on a sampled host from each group
- ✓The exception list is empty, or every entry has an owner and a date
- ✓The old private key is gone from the controller and from any backup taken during the window
5 · Rollback
If verification fails, undo the procedure in reverse order.
- ↶Rollback is only available while the old key is still authorised - that is the entire reason the add and remove steps are separated
- ↶If the new key fails after cutover but before removal: point the controller back at the old private key and re-verify; the fleet is unaffected
- ↶If removal has already run on some hosts, roll back by re-adding the old public key to those hosts using the NEW key, which still works
- ↶POINT OF NO RETURN: once the old private key is destroyed, there is no path back to it - do not destroy it until the full-fleet verification on the new key has passed
- ↶If both keys are lost on a host, recovery is out of band: console, cloud provider key injection, or the hosts provisioning system
6 · Escalation
When the runbook isn't enough, contact:
- · Escalate immediately if a host is unreachable at the removal stage - removing the old key from a host you cannot verify is how you create a permanently unmanaged machine
- · Escalate to the security owner if the rotation is due to suspected compromise: rotation becomes urgent and the old key must be removed rather than merely superseded
- · Escalate to the host owner for any machine that appears in the trust snapshot but not in inventory - an unmanaged host trusting the automation key is a finding in its own right
- · Escalate if break-glass access does not exist before starting; a key rotation without a second access path is a single point of failure by design
The automation SSH key is the credential that lets one host change every other host. Rotating it is routine and it is also the change most likely to lock your automation out of your own estate, because the tool you would use to fix the problem is the tool the change just broke.
The whole procedure is built around one rule: add everywhere, verify everywhere, then remove. Every failure mode in this runbook comes from compressing those three phases into one.
When to use this runbook
- Scheduled rotation on the estate’s key lifetime.
- An operator with controller access has left the team.
- The key is suspected or known to be compromised (see the ordering note below - this case is different).
- Migrating to a stronger key type.
- Replacing a controller that generated its own key.
Ordering when the key is compromised
If this rotation is a response to compromise, the phases stay in the same order but the clock changes. Adding the new key is still first, because you need a working path. But removal is no longer a scheduled tail-end task - it is the point of the exercise, and a host that cannot be reached to have the old key removed is an actively exposed host, not a paperwork item. Escalate it the same hour, do not carry it in the exception list for a week.
Blast radius
Every host that trusts the automation key. That is usually more than the primary inventory contains: decommissioned-but-alive machines, appliances, a jump host, a partner-managed box someone added once.
Step 2 exists to find those. Rotating only what is in inventory leaves the old key valid somewhere, which means the rotation did not happen.
Inputs
- The current automation private key path and its fingerprint.
- The complete list of hosts that trust it.
- The break-glass access path, tested.
- The change window, and a second window for removal.
Step 1: Generate the new keypair
sudo -iu ansible
ssh-keygen -t ed25519 -f /home/ansible/.ssh/id_ed25519_new \
-C "ansible-controller rotation 2026-08 - replaces SHA256:REPLACE_ME" -N ''
ssh-keygen -lf /home/ansible/.ssh/id_ed25519_new.pub
ssh-keygen -lf /home/ansible/.ssh/id_ed25519.pubRecord both fingerprints. You will compare against them at every later step, and “the old one” stops being an unambiguous phrase about twenty minutes into a rotation.
The comment field is not decoration. It is what tells the next person
looking at an authorized_keys file which key this is and what it
replaced.
A passphrase-less key is the norm for unattended automation; the
protection is file permissions and the fact that the controller is
itself a controlled host. If your estate uses an agent with a
passphrase, load it now and confirm ssh-add -l lists the new key
before continuing.
Step 2: Snapshot who trusts the old key
- name: Record which hosts trust the automation key
hosts: all
gather_facts: false
tasks:
- name: Read the automation authorized_keys
ansible.builtin.slurp:
src: /home/ansible/.ssh/authorized_keys
register: akf
become: true
- name: Report the keys present
ansible.builtin.debug:
msg: "{{ akf.content | b64decode | split('\n') | select('search', 'ssh-') | list | length }} key(s)"The important output of this step is not the count. It is the list of hosts that answered, compared against the list of hosts you believed existed. Anything trusting the key that is not in inventory goes to its owner now.
Step 3: Add the new key alongside the old
- name: Stage the new automation key
hosts: all
gather_facts: false
become: true
tasks:
- name: New public key is authorised, old one untouched
ansible.posix.authorized_key:
user: ansible
key: "{{ lookup('file', '/home/ansible/.ssh/id_ed25519_new.pub') }}"
state: present
exclusive: falseRun it, and note the recap. Unreachable hosts here are the whole point of Step 5.
Step 4: Verify the new key, using only the new key
ansible all -m ping -o \
-e 'ansible_ssh_private_key_file=/home/ansible/.ssh/id_ed25519_new' \
-e 'ansible_ssh_extra_args="-o IdentitiesOnly=yes"'
echo "exit=$?"IdentitiesOnly=yes is what makes this a real test. Without it, SSH
offers every key the agent holds and every default identity file, so a
host that does not have the new key still authenticates with the old
one and reports SUCCESS. You would then remove the old key from a host
that never received the new one.
Expected: SUCCESS for every host, exit code 0. Exit code 4 means
at least one host was unreachable; that host does not proceed to
removal.
Step 5: Write down the exceptions
Every host that failed Step 3 or Step 4 goes on a named list with an owner. Not a mental note, not a scrollback buffer.
ansible all -m ping -o \
-e 'ansible_ssh_private_key_file=/home/ansible/.ssh/id_ed25519_new' \
-e 'ansible_ssh_extra_args="-o IdentitiesOnly=yes"' \
2>&1 | grep -E 'UNREACHABLE|FAILED' | awk '{print $1}' | sort -u \
> rotation-exceptions.txt
wc -l rotation-exceptions.txtThese hosts keep the old key. They must therefore be excluded from Step 8 by name, and they block the closeout in Step 11. A rotation with an open exception list is a rotation in progress, not a completed one.
Step 6: Cut the controller over
cd /home/ansible/.ssh
cp -a id_ed25519 id_ed25519.pre-rotation
cp -a id_ed25519.pub id_ed25519.pub.pre-rotation
install -m 0600 id_ed25519_new id_ed25519
install -m 0644 id_ed25519_new.pub id_ed25519.pub
ssh-keygen -lf id_ed25519.pubKeep the old private key on disk for now, named so nobody mistakes it for the live one. It is your rollback, and Step 10 is where it goes away.
Step 7: Full read-only pass on the new key
ansible all -m ping -o | tee rotation-new-key-proof.txt
echo "exit=$?"
grep -c SUCCESS rotation-new-key-proof.txtThis is now the default identity, so no extra flags. Compare the SUCCESS count against the host count from the inventory. They must match, minus the exception list.
Run something slightly heavier as well - a check-mode playbook run -
because ping proves authentication and not much else. A key that
authenticates but whose account lost sudo in the same change window
will pass ping and fail everything real.
Step 8: Remove the old key, in waves
- name: Retire the old automation key
hosts: all
gather_facts: false
become: true
serial: "20%"
tasks:
- name: Prove the CURRENT connection uses the new key before removing anything
ansible.builtin.command: ssh-add -L
delegate_to: localhost
run_once: true
changed_when: false
- name: Old public key is no longer authorised
ansible.posix.authorized_key:
user: ansible
key: "{{ lookup('file', '/home/ansible/.ssh/id_ed25519.pub.pre-rotation') }}"
state: absent
- name: Connection still works after removal
ansible.builtin.ping:# Build an explicit exclusion pattern from the exception list
LIMIT="all$(sed 's/^/:!/' rotation-exceptions.txt | tr -d '\n')"
echo "$LIMIT"
ansible-playbook retire-old-key.yml --limit "$LIMIT" --list-hosts
ansible-playbook retire-old-key.yml --limit "$LIMIT" --diffserial: "20%" means a mistake stops after a fifth of the fleet rather
than all of it. The final ping task inside the same play is the wave
gate: if removing the old key breaks the connection, the play fails on
that batch and the remaining batches never run.
Verified behaviour worth relying on: with serial set and a batch
failure that exceeds max_fail_percentage, later batches are not
attempted and the untouched hosts do not appear in the recap at all.
Do not read an empty recap line as “that host was fine”.
Step 9: Prove the old key is dead
Verification that cannot fail is not verification. This one can:
for h in web01.example.com db01.example.com cache01.example.com; do
if ssh -o BatchMode=yes -o IdentitiesOnly=yes \
-i /home/ansible/.ssh/id_ed25519.pre-rotation \
-o ConnectTimeout=10 "ansible@$h" true 2>/dev/null; then
echo "STILL TRUSTED: $h"
else
echo "revoked OK: $h"
fi
doneSample at least one host per group. STILL TRUSTED on any of them means
the removal did not take on that host - most often because the file it
edited is not the file sshd reads.
ansible all -b -m command \
-a "sshd -T -C user=ansible,host=%h,addr=192.0.2.1" -o \
| grep -iE 'authorizedkeysfile|authorizedkeyscommand'If authorizedkeyscommand is anything other than none, keys come
from a helper - LDAP, a key server, a certificate authority - and
editing files on the host revokes nothing. Revoke at that source
instead, and treat every file-based step above as having done nothing
for those hosts.
Step 10: Destroy the old private key
shred -u /home/ansible/.ssh/id_ed25519.pre-rotation
rm -f /home/ansible/.ssh/id_ed25519.pub.pre-rotation
ls -l /home/ansible/.ssh/Then deal with copies: any controller backup taken during the window contains the old private key. Either purge those snapshots or record that they contain a retired credential and when they expire. A key “rotated” out of the live filesystem and left in a backup that anyone can restore has not been rotated.
Step 11: Close the exception list
Every host on rotation-exceptions.txt is now one of:
- Done - it came back, got the new key, and had the old one removed. Re-run Steps 3, 4 and 8 against it individually.
- Quarantined - it cannot be reached and has been formally recorded as trusting a retired key, with an owner and a date. If the rotation was due to compromise this is an open security finding, not an operations backlog item.
- Decommissioned - it is gone, and its removal is recorded.
There is no fourth category. “We will get to it” is how a retired key stays valid on a host for two years.
Rollback
| Stage reached | Rollback |
|---|---|
| New key generated, nothing distributed | Delete the new keypair. No fleet impact. |
| New key added alongside old (Step 3) | None needed - both keys work. Optionally remove the new one with state: absent. |
| Controller cut over (Step 6) | Restore id_ed25519.pre-rotation over id_ed25519, re-verify with ping. Fleet untouched. |
| Old key removed on some hosts (Step 8) | Re-add the old public key to those hosts using the new key, which still authenticates. |
| Old private key destroyed (Step 10) | No rollback. Recovery is out-of-band access only. |
cd /home/ansible/.ssh
install -m 0600 id_ed25519.pre-rotation id_ed25519
install -m 0644 id_ed25519.pub.pre-rotation id_ed25519.pub
ansible all -m ping -o; echo "exit=$?"Common patterns
| Symptom | Likely cause | Resolution |
|---|---|---|
| Verify passes on a host that never got the new key | IdentitiesOnly not set, so SSH fell back to the old key | Re-verify with -o IdentitiesOnly=yes and only the new identity file |
Removal reports ok but the old key still works | Matched on the fingerprint instead of the blob | Fingerprint the file with ssh-keygen -lf; remove by key material |
| Removal takes on most hosts, not on a few | Those hosts use AuthorizedKeysCommand or a Match block with a different path | sshd -T -C user=ansible ... to find the real source; revoke there |
| Fleet-wide lockout right after Step 3 | exclusive: true was set | Out-of-band access; re-add a key from console |
New key works for ping, everything else fails | The account lost sudo, or .ssh permissions changed | ssh -T host 'sudo -n id'; check stat on .ssh and authorized_keys |
| Hosts silently skipped in the removal wave | max_fail_percentage aborted the play; untouched hosts are absent from the recap | Compare the recap host list against --list-hosts, not against expectation |
| Excluded hosts were included anyway | --limit 'all:!@file' - a limit file cannot be negated and the exclusion is dropped | Build an explicit :!host pattern; --list-hosts before running |
Escalation
Escalate when:
- A host is unreachable at removal time. Removing a credential from a machine you cannot verify creates an unmanageable host.
- The rotation is a compromise response. Timelines change and the security owner runs the decision, not the change queue.
- A host trusts the automation key but is not in inventory.
- Break-glass access has not been tested. Test it before Step 8, not during the incident that follows Step 8.