Skip to main content
RunBook Academy

← All runbooks in Ansible

medium riskservice affecting~45 min

Runbook: Onboard a managed host

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 host owner has agreed to it being managed, and knows the first converge may restart services
  • · The host is reachable on the management path from the controller, directly or through the declared bastion
  • · A supported Python interpreter is present on the host, or the bootstrap path for installing one is decided
  • · The host is not already managed by another controller or another configuration management system
  • · The function group this host belongs to exists in the inventory, and the roles that group applies have been read
  • · A maintenance window exists for the first non-check converge

3 · Procedure

Execute each step in order. Verify the expected output of a step before moving to the next.

  1. 1Record the pre-onboarding state of the host: running services, key config files, package versions
  2. 2Establish the host key in known_hosts from a trusted channel, before any automation connects
  3. 3Install the automation public key for the automation account and confirm interactive SSH works
  4. 4Grant the narrowest sudo rule the roles actually need, and confirm it non-interactively
  5. 5Confirm the remote Python interpreter and record which one Ansible selects
  6. 6Add the host to inventory in its function group but NOT yet in any lifecycle group that a rollout targets
  7. 7Run ping, then setup, then the site playbook in check mode with diff
  8. 8Review the check-mode diff line by line with the host owner - this is the last cheap chance to catch a destructive default
  9. 9Converge for real inside the maintenance window, watching the first run to completion
  10. 10Verify service health, then add the host to its lifecycle groups so future rollouts include it

4 · Verification

Confirm the procedure actually fixed the problem.

  • ansible <host> -m ping returns SUCCESS and the run exits 0
  • ssh-keyscan output matches the fingerprint recorded in known_hosts, from a second source
  • The automation account can run its required commands under sudo without a password and without a TTY
  • ansible <host> -m setup returns facts, and ansible_python_interpreter is the one you expected
  • The second consecutive real converge reports changed=0 - a run that keeps changing things is not converged
  • Every service that was running before the converge is running after it
  • The host appears in ansible-inventory --graph under both its function group and its lifecycle group

5 · Rollback

If verification fails, undo the procedure in reverse order.

  • Before the first non-check converge, rollback is free: remove the host from inventory and remove the automation key
  • After the first real converge, the host has been changed - removing it from inventory does not undo those changes
  • To back out a converged host: restore the config files captured in Step 1, restart the affected services, then remove inventory entry and key
  • Remove the automation public key from the host authorized_keys and remove the sudoers drop-in
  • Confirm removal by attempting a connection that must now fail: ansible <host> -m ping must report UNREACHABLE
  • Record the host as unmanaged again, so a later inventory reconciliation does not silently re-add it

6 · Escalation

When the runbook isn't enough, contact:

  • · Escalate to the host owner before the first real converge if the check-mode diff shows a change to a file the owner did not expect to be managed
  • · Escalate to the security owner if the host requires broader sudo than the roles justify - widen the rule only with a recorded decision
  • · Escalate to the platform owner if the host is already managed by another system; two systems enforcing different states will fight and the loser is whichever ran first
  • · Escalate if no supported Python interpreter can be installed - the host may need the raw bootstrap path or may not be a candidate for management at all

Onboarding is the moment a host stops being something a person occasionally logs into and starts being something a playbook converges without asking. The first real run is the risky one: every role in its function group applies at once, to a host that has never had them applied, and every default that was fine on a freshly built machine now lands on one that has been in service for three years.

This runbook front-loads that risk into a check-mode review that the host owner reads.

When to use this runbook

  • A newly provisioned host is joining the managed estate.
  • A long-lived host is being brought under management for the first time.
  • A host is moving from another configuration management system.
  • A host that was removed from management is coming back.

Blast radius

One host - but every role that its function group applies.

The number that matters is not “one host”, it is “how many roles will run against it for the first time”. Read the group’s role list before Step 7, because that is the list of things about to change on a machine that has never seen them.

Inputs

  • Hostname, management IP, SSH port, and the bastion if there is one.
  • The function group it joins, and that group’s role list.
  • The host owner and their agreement.
  • The maintenance window for Step 9.
  • The sudo policy the roles require.

Step 1: Record the pre-onboarding state

Do this before anything else. It is both your rollback material and your evidence for what the converge actually changed.

Read-only / Safecapture baseline
HOST=web03.example.com
ssh "$HOST" 'systemctl list-units --type=service --state=running --no-pager --plain' \
> "baseline-$HOST-services.txt"
ssh "$HOST" 'rpm -qa 2>/dev/null || dpkg-query -W' \
| sort > "baseline-$HOST-packages.txt"
ssh "$HOST" 'sudo tar -C /etc -cf - ssh sudoers.d sysctl.d' \
> "baseline-$HOST-etc.tar"

Keep these off the host. A baseline stored on the machine you are about to reconfigure is a baseline you may not be able to read when you need it.

Step 2: Establish the host key from a trusted channel

Read-only / Safessh-keyscan
ssh-keyscan -t ed25519 web03.example.com | tee /tmp/newhost.pub

# Compare against a source that is NOT the network path you just used:
#   the provisioning system's record, the console, or the build log
ssh-keygen -lf /tmp/newhost.pub

Then add it deliberately:

Configuration changeansible.builtin.known_hosts
- name: Trust the host key for the new managed host
ansible.builtin.known_hosts:
  name: web03.example.com
  key: "{{ lookup('file', '/tmp/newhost.pub') }}"
  path: /home/ansible/.ssh/known_hosts
  state: present
delegate_to: localhost

Step 3: Install the automation public key

The bootstrap connection here is a human one - your own account with your own key - because the automation account does not have access yet.

Configuration changebootstrap the automation account
- name: Bootstrap automation access on a new host
hosts: web03.example.com
become: true
vars:
  ansible_user: "{{ bootstrap_admin_user }}"
tasks:
  - name: Automation service account exists
    ansible.builtin.user:
      name: ansible
      shell: /bin/bash
      create_home: true
      state: present

  - name: Controller public key is authorised
    ansible.posix.authorized_key:
      user: ansible
      key: "{{ lookup('file', '/home/ansible/.ssh/id_ed25519.pub') }}"
      state: present
      exclusive: false

Then confirm by hand before trusting it:

Read-only / Safessh as the automation account
sudo -iu ansible ssh -o BatchMode=yes web03.example.com 'id; hostname -f'

BatchMode=yes matters. Without it, SSH may fall back to a password prompt and you will conclude that key authentication works when what worked was you typing a password.

Step 4: Grant the narrowest sudo rule that works

Configuration changesudoers drop-in
# /etc/sudoers.d/50-ansible  (mode 0440, root:root)
ansible ALL=(root) NOPASSWD: ALL
Defaults:ansible !requiretty

NOPASSWD: ALL is the honest starting point for a general-purpose configuration management account, and pretending otherwise produces sudoers files full of command paths that the next role breaks. What makes it defensible is the rest of the design: a dedicated account, a key that only the controller holds, and an audit trail. If your estate requires narrower, narrow it to the specific commands the group’s roles run and expect to revisit the file whenever a role changes.

Validate before installing, always:

Configuration changevisudo -c
sudo visudo -cf /etc/sudoers.d/50-ansible
sudo install -m 0440 -o root -g root /tmp/50-ansible /etc/sudoers.d/50-ansible

A syntactically invalid file in sudoers.d breaks sudo for everyone on the host, including the account you would use to fix it. visudo -c before install is the difference between a change and an incident.

Confirm it works the way automation will use it - non-interactive, no TTY:

Read-only / Safeverify sudo non-interactively
sudo -iu ansible ssh -T -o BatchMode=yes web03.example.com 'sudo -n id'

ssh -T suppresses the TTY, which is exactly how Ansible connects. A sudo rule that works when you are logged in and fails without a TTY is the classic requiretty failure, and it will present as a become failure on the first converge.

Step 5: Confirm the remote interpreter

Read-only / Safeansible -m setup
ansible -i inventories/staging web03.example.com -m setup \
-a 'filter=ansible_python*'

Read ansible_python_interpreter and ansible_python.version. Ansible discovers an interpreter per host, and two hosts in the same group choosing different ones is a real source of “works on one, fails on the other”.

If no Python is present at all, setup fails and the bootstrap needs ansible.builtin.raw, which is the one module that does not require a remote interpreter:

Configuration changeraw bootstrap
- name: Install a Python interpreter before anything else can run
ansible.builtin.raw: |
  test -e /usr/bin/python3 || (apt-get update && apt-get install -y python3)
changed_when: false

raw is a bootstrap tool, not a general escape hatch. It has no idempotency, no check-mode support and no return structure. The moment Python is present, stop using it.

Step 6: Add to inventory - function group only

Configuration changeinventory entry
all:
children:
  web:
    hosts:
      web03.example.com:
        ansible_host: 192.0.2.13
  # deliberately NOT yet a member of wave1 / canary / patch-tuesday

This is the step people skip and regret. Adding the host to its lifecycle groups at the same time as its function group means the next scheduled fleet run picks it up before anyone has converged it deliberately. Onboard first, enrol in rollouts afterwards.

Read-only / Safeconfirm placement
ansible-inventory -i inventories/staging --graph
ansible-inventory -i inventories/staging --host web03.example.com

Step 7: Ping, facts, then check mode

Read-only / Safegraduated verification
ansible -i inventories/staging web03.example.com -m ping -o
ansible -i inventories/staging web03.example.com -m setup >/dev/null && echo 'facts OK'
ansible-playbook -i inventories/staging site.yml \
--limit web03.example.com --check --diff | tee "check-web03.txt"

Three connections of increasing depth: transport, interpreter, then the whole site playbook without changing anything.

Step 8: Read the diff with the host owner

This is the review that makes the difference between onboarding and breaking someone’s server.

Look specifically for:

  • A config file being replaced wholesale that the host owner had hand-edited.
  • A service being disabled because the role’s default says it should be.
  • A user or group being removed because it is not in the declared list.
  • Firewall rules being replaced with the group’s standard set.
  • A package being downgraded to the version the role pins.

Check mode also has limits worth stating out loud here: modules that do not support it are skipped, and tasks conditioned on the result of a command that did not run are evaluated against nothing. A clean check run is necessary, not sufficient.

Step 9: Converge for real

Inside the maintenance window, with the owner available.

Service impact possibleansible-playbook converge
ansible-playbook -i inventories/staging site.yml \
--limit web03.example.com --diff | tee "converge-web03.txt"

Point of no return. From this command onwards the host has been changed. Inventory edits no longer undo anything; only the restore path in the rollback section does.

Watch it to completion. Do not start it and walk away on a first converge - the failure you want to catch is a service that restarts and does not come back, and that is a minute of attention, not an hour.

Step 10: Verify, then enrol in rollouts

Read-only / Safeverify convergence
# Idempotence: the second run must change nothing
ansible-playbook -i inventories/staging site.yml \
--limit web03.example.com --diff

# Services that were running before are running now
ssh web03.example.com 'systemctl list-units --type=service --state=running --no-pager --plain' \
> after-web03-services.txt
diff "baseline-web03.example.com-services.txt" after-web03-services.txt

A second run reporting changed=0 is the real definition of converged. A run that keeps reporting changes on every execution contains a non-idempotent task, and that task will show up as permanent noise in every drift report from now on - which is how drift reports stop being read.

Only now add the host to its lifecycle groups:

Configuration changeenrol in lifecycle groups
    wave1:
    hosts:
      web03.example.com:

Rollback

Before Step 9, rollback is removing the inventory entry and the key. After Step 9, the host has been changed and must be restored.

Service impact possibleback out a converged host
HOST=web03.example.com

# 1. Restore the configuration captured in Step 1
scp "baseline-$HOST-etc.tar" "$HOST:/tmp/"
ssh "$HOST" 'sudo tar -C /etc -xf /tmp/baseline-'"$HOST"'-etc.tar'

# 2. Restart what the roles touched, using the run log as the list
ssh "$HOST" 'sudo systemctl daemon-reload && sudo systemctl restart nginx'

# 3. Remove automation access
ssh "$HOST" 'sudo rm -f /etc/sudoers.d/50-ansible'
ssh "$HOST" 'sudo userdel -r ansible'

# 4. Remove the inventory entry, then prove access is gone
ansible -i inventories/staging "$HOST" -m ping -o ; echo "expect rc=4, got rc=$?"

The verification in step 4 is the part people omit. An onboarding that was backed out but left the key in place is a host that is not in inventory and still trusts the controller - invisible and reachable at the same time.

Record the host as unmanaged, or the next inventory reconciliation against the CMDB will add it straight back.

Common patterns

SymptomLikely causeResolution
UNREACHABLE on first pingHost key not trusted, wrong user, wrong port, bastion not configuredssh -v by hand as the automation account
Connects but every task fails at becomerequiretty in sudoers, or the drop-in was never installedssh -T ... 'sudo -n id'; check visudo -c passed
setup fails, ping succeededping needs a remote interpreter too - if ping worked, Python exists; suspect a filter typo insteadRun -m setup with no filter
Check-mode run proposes hundreds of changesNormal for a long-lived host on first convergeRead it with the owner; do not proceed on skim
Second converge still reports changesA non-idempotent task, usually command/shell without changed_whenFix the task before enrolling the host in rollouts
Host picked up by a fleet run before you converged itIt was added to a lifecycle group in Step 6Remove it from the lifecycle group; enrol only after Step 10
A service that was running is now stoppedA role default disabled itRestore from baseline; add the exception to group_vars, not to the host

Escalation

Escalate when:

  • The check-mode diff shows a change to something the host owner did not expect to be managed. That is a conversation about ownership, not a technical decision.
  • The host needs broader privilege than the roles justify.
  • Another configuration management system is already enforcing state here. Two systems with different opinions produce a host that flips between them, and the symptom is drift that “fixes itself” and comes back.
  • No supported interpreter can be installed.

References

  1. How to build your inventory
  2. ansible.builtin.known_hosts module
  3. ansible.builtin.setup module
  4. sudoers(5)