Skip to main content
RunBook Academy

AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution

Which machine does this task run on?

Advanced⏱ ~22 minansible-core

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

Not yet marked complete on this device.

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.

Read-only / Safefour tasks, four contexts
$ ansible-playbook -i local4.ini local4.yml
TASK [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]localhost is the target. There is no web1 here at all, which is why app_port is 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.

Read-only / Safethe diagnostic
- 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:

  1. Copy the real task’s delegate_to, connection and become lines onto the diagnostic verbatim. A diagnostic that does not carry the same keywords is measuring a different task.
  2. 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', ...). A lookup('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.

Read-only / Safeshow the connection
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.

  1. Which machine executes the module? Read delegate_to, connection and local_action. Confirm with the arrow in the output.
  2. How many times will it run? Count the play’s hosts. Look for run_once and throttle.
  3. Whose variables does the module see? Next lesson. The answer is not the one most people give.
  4. Is the delegate in inventory? If not, it has no group_vars, no host_vars and no facts. Lesson 7.
  5. 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

  1. Q1. A run prints "ok: [web4 -> lb1]" for a task. What does that tell you?

  2. 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?

  3. Q3. Which of these are evaluated on the controller regardless of delegation or connection settings? Select all that apply.

  4. 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.