Ansible reads site.yml top to bottom, parses the YAML into an internal list of plays, and validates syntax before touching any host. A syntax error here means zero hosts get touched — this is a fully pre-flight step.
ParseThe play's hosts: value is matched against the inventory (static file, dynamic script, or -i override). Patterns like web:&!staging get resolved into a concrete list of hostnames before anything connects.
Ansible is agentless — no daemon runs persistently on the managed node. For each host, it opens an SSH connection using the configured user/key, exactly like you'd do manually. Multiple hosts are processed in parallel, bounded by the forks setting (default 5).
ConnectUnless gather_facts: false, Ansible's first real action on the host is running the setup module — a script that inspects OS, IP addresses, disk, memory, etc., and returns it as JSON. These facts become variables (ansible_facts) usable in every later task.
By this point Ansible has: validated syntax, resolved the host list, opened a connection, and gathered facts. Nothing on the managed node has changed yet — everything so far is read-only reconnaissance.
CheckpointFor the first task, Ansible takes the module (e.g. apt) and its arguments, substitutes any Jinja2 variables ({{ }}), and assembles a self-contained Python script with the resolved arguments embedded.
RenderThat assembled script is copied over SSH (SFTP/SCP) to a temp path on the managed node, e.g. ~/.ansible/tmp/. This is why Ansible needs write access to the remote home directory, not just root privilege for the task itself.
The remote Python interpreter runs the uploaded script. If become: true is set, it's re-invoked through sudo (or another privilege escalation method) first. The module does its actual work — installing a package, writing a file, starting a service.
ExecuteEvery module — no exceptions — returns a JSON blob over stdout: at minimum changed (true/false), failed, and any module-specific data. This is the single mechanism Ansible uses to know what happened; there's no separate side-channel.
ResultThe uploaded module script is deleted from the managed node (unless ANSIBLE_KEEP_REMOTE_FILES=1 is set for debugging), leaving no persistent footprint.
If the task used notify: and reported changed: true, the named handler is queued — but not run yet. Handlers only fire once, at the end of the play, after every task has been attempted.
NotifySteps 6-11 repeat for every task in the play, and the whole per-host sequence runs in parallel across the host batch (bounded by forks), before Ansible moves to the next batch.
LoopAny queued handlers execute once at the end. Ansible then prints the PLAY RECAP — ok/changed/unreachable/failed counts per host — which is the actual source of truth for whether the run succeeded.
Finishansible_facts variables are usable anywhere in the play.| Term | Meaning |
|---|---|
| Control Node | The machine running ansible-playbook itself — where inventory and playbooks live |
| Managed Node | A target host being configured — needs only SSH/WinRM + Python, no agent |
| Play | One block in a playbook mapping a set of hosts to a set of tasks |
| Task | A single module invocation with specific arguments |
| forks | Max number of hosts processed in parallel per task (default 5) |
| Facts | Host attributes (OS, IP, memory) auto-gathered via the setup module |
| Handler | A task that only runs when notified, and only once, at the end of the play |
| become | Privilege escalation (e.g. sudo) applied to a task's execution |
| changed | The JSON field a module returns to report whether it altered state |
| Play Recap | The final ok/changed/unreachable/failed summary printed per host |