Skip to main content
RunBook Academy

← All runbooks in Ansible

high riskservice affecting~90 min

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.

  1. 1Generate the new keypair on the controller with a distinguishing comment
  2. 2Snapshot the current trust state: which hosts have the old key, by fingerprint
  3. 3Add the new public key to every host ALONGSIDE the old one - do not use exclusive mode
  4. 4Verify the new key works on every host, using the new key explicitly and nothing else
  5. 5Record the hosts where the add or the verify failed - these are now a tracked exception list, not a footnote
  6. 6Cut the controller over to the new key: update ansible.cfg or the inventory connection variables
  7. 7Run a full read-only pass on the new key to confirm the whole fleet is reachable with it
  8. 8Remove the old public key from every host, in waves, with verification between waves
  9. 9Prove the old key no longer authenticates anywhere, by attempting it and requiring failure
  10. 10Destroy the old private key on the controller and in any backup that holds it
  11. 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

Configuration changessh-keygen
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.pub

Record 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

Read-only / Safefingerprint the trust
- 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

Configuration changeauthorized_key state=present
- 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: false

Run 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

Read-only / Safeverify with the new identity
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.

Read-only / Safeextract the failures
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.txt

These 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

Configuration changeswap the identity
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.pub

Keep 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

Read-only / Safefull fleet proof
ansible all -m ping -o | tee rotation-new-key-proof.txt
echo "exit=$?"
grep -c SUCCESS rotation-new-key-proof.txt

This 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

Service impact possibleauthorized_key state=absent
- 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:
Service impact possiblerun the removal excluding exceptions
# 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" --diff

serial: "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:

Read-only / Saferequire the old key to fail
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
done

Sample 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.

Read-only / Safeask sshd where it reads keys from
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

Destructiveshred 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 reachedRollback
New key generated, nothing distributedDelete 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.
Configuration changerevert the controller to the old key
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

SymptomLikely causeResolution
Verify passes on a host that never got the new keyIdentitiesOnly not set, so SSH fell back to the old keyRe-verify with -o IdentitiesOnly=yes and only the new identity file
Removal reports ok but the old key still worksMatched on the fingerprint instead of the blobFingerprint the file with ssh-keygen -lf; remove by key material
Removal takes on most hosts, not on a fewThose hosts use AuthorizedKeysCommand or a Match block with a different pathsshd -T -C user=ansible ... to find the real source; revoke there
Fleet-wide lockout right after Step 3exclusive: true was setOut-of-band access; re-add a key from console
New key works for ping, everything else failsThe account lost sudo, or .ssh permissions changedssh -T host 'sudo -n id'; check stat on .ssh and authorized_keys
Hosts silently skipped in the removal wavemax_fail_percentage aborted the play; untouched hosts are absent from the recapCompare 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 droppedBuild 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.

References

  1. ansible.posix.authorized_key module
  2. ssh-keygen(1)
  3. sshd_config(5)