AnsibleII · Ansible ArchitectureThe execution model
The push model and who can reach whom
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
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.
$ ansible-pull -U https://git.example.com/estate/config.git local.ymlStarting 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=0Illustrative output
What this changes, and what it does not:
Push (ansible-playbook) | Pull (ansible-pull) | |
|---|---|---|
| Who opens the connection | Controller to node | Node to repository |
| Works behind NAT | No, without a tunnel or local controller | Yes |
| Works on intermittent hosts | Only if up during the run | Converges when the host is up |
| Where the schedule lives | Controller | Each host |
| Central visibility of results | The recap, in one place | Each host reports separately, or not at all |
| Where credentials sit | Controller holds keys to every host | Each host holds a repository credential |
| Blast radius of a bad commit | Whatever the operator targets | Every host, on its own schedule, unattended |
| Ability to stop a bad rollout | Stop running it | Revert 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.
$ 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=4Exit 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:
- List the network segments that hold managed hosts. Not hosts — segments.
- For each, determine whether the controller can open SSH into it. From the controller, not from a workstation.
- 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.
- Decide what “unreachable” means operationally. Which team is paged, and after how many consecutive runs.
- 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
Q1. A healthy, correctly configured host at a branch office behind a consumer NAT router cannot be managed by a central Ansible controller. Why?
Q2. A pre-flight play whose only task is ansible.builtin.debug will correctly report which hosts in an inventory are unreachable.
Q3. Which of these are genuine advantages of the pull model over push? Select all that apply.
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.