Skip to main content
RunBook Academy

AnsibleII · Ansible ArchitectureThe execution model

The push model and who can reach whom

Intermediate⏱ ~19 minansiblessh

What you'll learn

  • Explain why the direction of connection establishment is an architectural constraint
  • Identify the network topologies that push automation cannot cross without design
  • Contrast push with pull, including where credentials and failure modes concentrate
  • Read an unreachable result as a statement about knowledge rather than about health

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Ansible is a push tool. The controller initiates every connection; a managed node never initiates anything.

That is one sentence and it constrains an entire architecture. Whether a host can be managed is determined by whether the controller can open a TCP connection to it — which is a question about firewalls, NAT, routing and uptime, not about the host’s health or configuration.

This lesson is about the consequences, and about the specific reading error the model invites: treating unreachable as a minor result rather than as the loudest thing in a recap.

Direction is the constraint

flowchart LR
  subgraph OFFICE["Controller network"]
    C["Controller"]
  end
  subgraph DC["Data centre"]
    A["app01"]
    A2["app02"]
  end
  subgraph DMZ["DMZ"]
    P["proxy01"]
  end
  subgraph BRANCH["Branch site, behind NAT"]
    B["branch-nas01"]
  end

  C -->|"SSH, permitted"| A
  C -->|"SSH, permitted"| A2
  C -->|"SSH, permitted"| P
  C -.->|"blocked: no inbound<br/>route to NAT interior"| B

Three of the four hosts in that picture are managed. branch-nas01 is not, and nothing about it is wrong: it is running, healthy, reachable from its own site, and it can reach the internet. It simply cannot be reached from where the controller sits, and in a push model that is the only reachability that counts.

The general shape of the constraint:

  • The controller must have a route to the host. Not the reverse.
  • A firewall must permit the connection in that direction. An egress rule on the host is irrelevant.
  • The host must be up at the moment the run happens. There is no queue and nothing retries later.
  • Name resolution must work from the controller. A host that resolves correctly from your desk and not from the controller is unmanaged.

Every one of those has an equivalent in a pull model, and in a pull model every one of them points the other way.

The topologies that make this hard

NAT, and hosts with no inbound path

Branch offices, home-lab equipment, customer-premises devices, anything behind a consumer router. The host can reach out; nothing can reach in.

Push has no answer to this on its own. The realistic options are a controller inside each network, a persistent tunnel or VPN that gives the controller a route, a bastion inside the network that the controller can reach, or ansible-pull on the host itself.

DMZ and segmented networks

A DMZ that permits SSH from a management segment is fine. A DMZ that permits SSH from nowhere is not, and the usual answer is a jump host inside the segment.

This is the case where the ssh connection plugin’s use of the system ssh client pays off directly. ProxyJump in ~/.ssh/config works transparently, so the inventory does not have to know about the topology at all:

Host dmz-*
  ProxyJump bastion.example.com
  User automation

Ansible drives your SSH client, so it inherits this without any Ansible-specific configuration. The SSH part of this course covers the alternatives and their trade-offs; the architectural point is that the capability comes from the transport being the real SSH client rather than a reimplementation.

Air-gapped estates

No route exists and none will be created. The controller must be inside the air gap, which means the repository, the collections and the Python packages must get in there too — a supply-chain problem rather than a connectivity one, and one that the collections and disaster-recovery parts of this course address.

Hosts that are not always on

Laptops, workstations, equipment that is powered down overnight, VMs that are suspended. A push run reaches whatever is up at that moment and reports the rest as unreachable.

Note the asymmetry with pull: a laptop that boots at 09:00 and runs ansible-pull converges itself at 09:00. A push run scheduled for 03:00 never touches it, and will never touch it, because the schedule and the host’s uptime do not overlap.

This is the clearest case where push is structurally the wrong model, and it is worth recognising rather than working around.

Pull, and ansible-pull

ansible-pull inverts the direction. It runs on the managed node, clones a repository, and applies a playbook to the local machine.

Configuration changethe inverted model, run on the node itself
$ ansible-pull -U https://git.example.com/estate/config.git local.yml
Starting Ansible Pull at 2026-08-11 03:14:02
localhost | SUCCESS => {"after": "9c1f2ae...", "before": "3d4b8e1...", "changed": true}

PLAY [Converge this host] ******************************************************
...
PLAY RECAP *********************************************************************
localhost   : ok=18  changed=2  unreachable=0  failed=0

Illustrative output

What this changes, and what it does not:

Push (ansible-playbook)Pull (ansible-pull)
Who opens the connectionController to nodeNode to repository
Works behind NATNo, without a tunnel or local controllerYes
Works on intermittent hostsOnly if up during the runConverges when the host is up
Where the schedule livesControllerEach host
Central visibility of resultsThe recap, in one placeEach host reports separately, or not at all
Where credentials sitController holds keys to every hostEach host holds a repository credential
Blast radius of a bad commitWhatever the operator targetsEvery host, on its own schedule, unattended
Ability to stop a bad rolloutStop running itRevert and wait for every host to notice

The last two rows deserve attention, because they are the reason push remains the default for production estates despite pull’s topology advantages.

In a push model, a bad change reaches the hosts an operator chose, at a time an operator chose, and stopping it means pressing Ctrl-C. In a pull model, a bad commit propagates to the entire fleet automatically as each host wakes up, and stopping it means reverting the commit and then waiting for every host to come round again. There is no --limit and no serial in a model where the host decides when to converge.

unreachable is a statement about knowledge

Now the reading error the push model invites, and it is important enough that this course returns to it repeatedly.

A run produces three interesting per-host outcomes:

  • ok / changed — the host was reached, the task ran, you know the outcome.
  • failed — the host was reached, the task ran, it did not work. You know the outcome.
  • unreachable — nothing ran. You know nothing at all about that host’s state.

The third is categorically different from the second, and the recap reports it in its own column for that reason.

Read-only / Safewhat an unreachable fleet actually looks like
$ ansible-playbook -i inv.ini reach.yml; echo "EXIT=$?"
TASK [A task that must connect] ************************************************
fatal: [web01]: UNREACHABLE! => {"changed": false, "msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 192.0.2.11 port 22: Connection timed out", "unreachable": true}

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

EXIT=4

Exit code 4 for unreachable hosts, established by execution rather than from documentation — the exit-code table is not published upstream. A task failure alone exits 2. A run containing both failures and unreachable hosts also exits 4, because unreachable takes precedence.

The operational consequence is specific and it bites CI pipelines: a job that tests only for exit code 2 to decide whether a run failed will treat a run containing both failures and unreachable hosts as a pass. Test for non-zero.

Designing for the topology you have

A short checklist, applied when placing a controller:

  1. List the network segments that hold managed hosts. Not hosts — segments.
  2. For each, determine whether the controller can open SSH into it. From the controller, not from a workstation.
  3. For segments it cannot reach, choose: a bastion the controller can reach, a tunnel, a controller inside the segment, or accept that the segment uses a pull model.
  4. Decide what “unreachable” means operationally. Which team is paged, and after how many consecutive runs.
  5. Instrument coverage, not just failure. The count of hosts reached per run, tracked over time.

Item 5 is the one almost nobody does, and it is the one that would have caught the scenario above in the first month.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A healthy, correctly configured host at a branch office behind a consumer NAT router cannot be managed by a central Ansible controller. Why?

  2. Q2. A pre-flight play whose only task is ansible.builtin.debug will correctly report which hosts in an inventory are unreachable.

  3. Q3. Which of these are genuine advantages of the pull model over push? Select all that apply.

  4. Q4. A monthly patch run reports 260 changed, 40 unreachable, 0 failed, and exits 4. The CI job checks for exit code 2 and marks the run successful. What are the two defects?

Passing score: 75%. Answers are checked in this browser.