AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution
Which machine does this task run on?
What you'll learn
- Name the three execution contexts a task can have and what decides which one applies
- Read the host field in Ansible output as evidence of where a task actually ran
- Prove the execution context of a task rather than inferring it from the playbook text
- Recognise the class of bug that comes from picturing the wrong machine
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
Here is a task. Where does it run?
- name: Record the deployment
ansible.builtin.uri:
url: https://deploy.example.com/api/v1/events
method: POST
body_format: json
body:
host: "{{ inventory_hostname }}"
version: "{{ release_version }}"
The answer decides whether this works at all. If it runs on each managed
node, every web server needs network access to deploy.example.com, and
an API token has to exist on every web server. If it runs on the
controller, neither is true — one machine reaches the API, and the token
stays on the machine that already holds your vault password.
Nothing in the YAML above says which. The default answer is “on each managed node”, and for this task that is almost certainly not what the author wanted.
This part is about that question, and the first thing it needs is the vocabulary and a way to get an answer you can trust.
Three contexts
A task executes in exactly one of three places.
The managed node. The default. Ansible connects to the host named by
inventory_hostname, ships the module there, runs it, collects the
result. Everything you have written so far in this course has done this.
The controller. The machine running ansible-playbook. Some things
always happen here — templating, filters, lookups — and a task can be
made to run its module here too, with connection: local,
delegate_to: localhost or local_action.
A third host. Neither the current target nor the controller. This is
delegate_to: <some other host>, and it is what makes “for each app
server, tell the load balancer to drain that app server” expressible in
one loop.
The third one is the interesting case and the rest of this part is mostly
about it. But you cannot reason about delegation until you can tell the
first two apart reliably, because the failure modes look identical from a
distance: a task that ran somewhere unexpected and reported ok.
The output already tells you
Ansible prints the execution context on every result line. It is easy to read past.
$ ansible-playbook -i local4.ini local4.ymlTASK [1. delegate_to localhost] ************************************************
ok: [web1 -> localhost] => {
"msg": "ih=web1 ah=192.0.2.11 port=8080 conn=local"
}
ok: [web2 -> localhost] => {
"msg": "ih=web2 ah=192.0.2.12 port=8081 conn=local"
}
TASK [2. connection local] *****************************************************
ok: [web1] => {
"msg": "ih=web1 ah=192.0.2.11 port=8080 conn=local"
}
ok: [web2] => {
"msg": "ih=web2 ah=192.0.2.12 port=8081 conn=local"
}
TASK [3. local_action] *********************************************************
ok: [web1 -> localhost] => {
"msg": "ih=web1 ah=192.0.2.11 port=8080"
}
ok: [web2 -> localhost] => {
"msg": "ih=web2 ah=192.0.2.12 port=8081"
}
TASK [4. separate localhost play] **********************************************
ok: [localhost] => {
"msg": "ih=localhost app_port=NOT VISIBLE"
}Three shapes appear in those host fields, and each means something specific:
ok: [web1]— the task ran in web1’s context. Whether the module executed over SSH or locally is a separate question, answered by the connection plugin, not by this line.ok: [web1 -> localhost]— the task is web1’s, and it executed somewhere else. The name after the arrow is the delegate.ok: [localhost]—localhostis the target. There is no web1 here at all, which is whyapp_portis not visible in task 4.
The arrow is the reliable signal. When you are reading somebody else’s run and trying to work out what happened, the arrow is the first thing to look for. No arrow means no delegation, whatever the playbook seemed to say.
Proving it, rather than believing the YAML
The output notation tells you where Ansible thinks it ran. For a task you are debugging — especially one whose behaviour makes no sense — you want the machine’s own answer.
- name: DIAGNOSTIC - which machine am I
ansible.builtin.command: hostname -f
changed_when: false
register: whereami
# ...whatever delegate_to / connection / local_action the real task has
- name: DIAGNOSTIC - report it
ansible.builtin.debug:
msg: "task for {{ inventory_hostname }} executed on {{ whereami.stdout }}"hostname is answered by the kernel of the machine the module process is
running on. It cannot be fooled by a variable, an inventory alias or a
misread delegate_to. changed_when: false is there because command
reports changed unconditionally and a diagnostic should not pollute your
change count.
Two details make this reliable rather than approximate:
- Copy the real task’s
delegate_to,connectionandbecomelines onto the diagnostic verbatim. A diagnostic that does not carry the same keywords is measuring a different task. - Use
hostname -f, not{{ ansible_hostname }}. The fact is a value the controller already holds and it will happily tell you about a host the task never touched. That is the exact confusion you are trying to resolve.
The parts that always run on the controller
Some of a task is evaluated on the controller no matter what the connection or delegation says, and knowing which parts removes a lot of confusion later.
Evaluated on the controller, always:
- Templating. Every
{{ }}in the task, including inside the module arguments. The module receives values, never expressions. - Filters.
| to_json,| regex_replace,| password_hash— Jinja runs in the controller’s Python. - Lookups.
lookup('file', ...),lookup('env', ...),lookup('community.hashi_vault.vault_kv2_get', ...). Alookup('file', '/etc/secret')reads the controller’s/etc/secret, which surprises people roughly once each. - Conditionals.
when:is evaluated before the module is dispatched.
Executed on the target — whichever target that turns out to be:
- The module code itself, for a normal module.
Reading -vvv for the connection line
When the arrow is not enough — a delegate that resolves through a variable, an inventory alias that shadows a real hostname — the verbose output shows the connection being established.
ansible-playbook -i inventory/production site.yml \
--limit web1 --check --tags healthcheck -vvv 2>&1 \
| grep -E 'ESTABLISH|SSH: EXEC|delegate'You are looking for the ESTABLISH SSH CONNECTION FOR USER line and the
address that follows it. That address comes from the connection layer and
is the ground truth about which machine was dialled.
A checklist for reading a delegated task
When you meet one in an unfamiliar repository, answer these in order. Each is answerable from the playbook plus one command.
- Which machine executes the module? Read
delegate_to,connectionandlocal_action. Confirm with the arrow in the output. - How many times will it run? Count the play’s hosts. Look for
run_onceandthrottle. - Whose variables does the module see? Next lesson. The answer is not the one most people give.
- Is the delegate in inventory? If not, it has no
group_vars, nohost_varsand no facts. Lesson 7. - What happens if the delegate is down? Every host that delegates to it fails, and the recap names those hosts rather than the delegate.
Knowledge check
Knowledge check · 4 questions
Q1. A run prints "ok: [web4 -> lb1]" for a task. What does that tell you?
Q2. A task uses lookup('file', '/etc/app/license.key') to read a licence file, with no delegation of any kind, in a play targeting 40 app servers. Which file is read?
Q3. Which of these are evaluated on the controller regardless of delegation or connection settings? Select all that apply.
Q4. Delegating a task to a single load balancer reduces the number of times that task runs.
Passing score: 75%. Answers are checked in this browser.