Skip to main content
RunBook Academy

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

delegate_to and the variables that follow it

Advanced⏱ ~26 minansible-core

What you'll learn

  • State which host supplies the connection settings for a delegated task and prove it
  • State which host supplies the variables a delegated task templates against and prove it
  • Reach the original host data from inside a delegated task with hostvars[inventory_hostname]
  • Write the drain-this-host pattern that only delegation makes expressible

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.

The pattern that justifies delegation existing looks like this:

- name: Drain this host from the pool
  community.general.haproxy:
    socket: /run/haproxy/admin.sock
    backend: web_backend
    host: "{{ inventory_hostname }}"
    state: disabled
  delegate_to: lb1

Read it twice. The module runs on lb1, because that is where the HAProxy admin socket lives. The host it disables is {{ inventory_hostname }} — the current app server, the one the play is iterating over.

One task, two machines, and the whole thing is expressible only because those two halves resolve differently. The connection goes to the load balancer. The variables come from the app server. If both followed the delegate, inventory_hostname would be lb1 and the task would drain the load balancer from itself.

This lesson establishes exactly where the line falls, with both halves demonstrated, because the rule stated in half is the source of most delegation bugs.

Half one: the connection follows the delegate

An inventory where the two hosts disagree about everything that matters for connecting:

[web]
web1 ansible_host=192.0.2.11 ansible_connection=local

[proxy]
lb1 ansible_host=192.0.2.200 ansible_user=proxyops ansible_port=2222

web1 connects locally. lb1 claims a documentation-range address, an unusual user and a non-default port — none of which can succeed, which is what makes the error message useful evidence.

Read-only / Safea delegated ping that cannot land
$ ansible-playbook -i deleg2.ini deleg2.yml -T 5
TASK [Try to reach the delegate] ***********************************************
fatal: [web1 -> lb1]: UNREACHABLE! => {"changed": false, "msg": "Task failed:
Failed to connect to the host via ssh: ssh: connect to host 192.0.2.200
port 2222: Connection timed out", "unreachable": true}

The play targets web1, whose connection is local. The attempt went to 192.0.2.200 on port 2222 over SSH — lb1’s address, lb1’s port, lb1’s transport. Three connection variables, all taken from the delegate.

That is the first half of the rule, and it is the half that makes delegation work at all:

Connection settings come from the delegated host. ansible_host, ansible_port, ansible_user, ansible_connection and the ansible_python_interpreter used to run the module are resolved from the delegate, not the current target.

Upstream states the same thing in the delegate_to keyword description: “Connection vars from the delegated host will also be used for the task.”

Half two: the template does not

Now the half people are surprised by. Same shape, both hosts using local connections so the task actually completes, and each host carrying its own ansible_host, ansible_user and an ordinary variable:

Read-only / Safewhat the task template sees
$ ansible-playbook -i deleg3.ini deleg3.yml
TASK [Undelegated baseline] ****************************************************
ok: [web1] => {
  "msg": "BASE host=192.0.2.11 user=appsvc"
}

TASK [Delegated] ***************************************************************
ok: [web1 -> lb1] => {
  "msg": "DELEG host=192.0.2.11 user=appsvc lb1_user=proxyops"
}

Inside the delegated task, {{ ansible_host }} rendered 192.0.2.11 — web1’s value, not lb1’s 192.0.2.200. {{ ansible_user }} rendered appsvc, not proxyops. The delegate’s values were reachable, but only by asking for them explicitly through hostvars['lb1'].

An ordinary, non-connection variable behaves the same way. With app_port=8080 on web1 and app_port=9999 on lb1:

Read-only / Safeordinary variables stay with the original host
$ ansible-playbook -i deleg.ini deleg.yml
TASK [Delegated task, showing whose variables are in scope] *********************
ok: [web1 -> lb1] => {
  "msg": "inventory_hostname=web1 | ansible_host=192.0.2.11 | app_port=8080
          | delegated_host_var=9999
          | original_via_hostvars=192.0.2.11"
}
ok: [web2 -> lb1] => {
  "msg": "inventory_hostname=web2 | ansible_host=192.0.2.12 | app_port=8081
          | delegated_host_var=9999
          | original_via_hostvars=192.0.2.12"
}

So the second half of the rule:

The variable namespace the task templates against stays with the original host. inventory_hostname, host and group variables, facts and registered results are the current target’s throughout. The delegate’s data is available through hostvars['<delegate>'] and nowhere else implicitly.

Together, those two halves are the entire behaviour, and together they are exactly what the HAProxy example needed.

Writing the pattern properly

The drain step, complete, with the parts that make it survive contact with a real fleet:

Service impact possibledrain one app server via the load balancer
- name: Take this host out of the pool
community.general.haproxy:
  socket: /run/haproxy/admin.sock
  backend: "{{ haproxy_backend }}"
  host: "{{ inventory_hostname }}"
  state: disabled
  wait: true
delegate_to: "{{ groups['loadbalancers'] | first }}"
throttle: 1

Four decisions in there worth naming:

  • host: "{{ inventory_hostname }}" is the app server, because variables stay with the original host. This is the line the whole pattern rests on.
  • backend: "{{ haproxy_backend }}" also resolves from the app server, so a group_vars/web_stage.yml and a group_vars/web_prod.yml can point at different pools without the task changing.
  • The delegate comes from a group rather than a literal, so the playbook does not carry a hostname that will be wrong after the next proxy rebuild.
  • throttle: 1 because every host in the play delegates to the same machine. Without it, forks app servers hit one admin socket at once. That keyword is the subject of a lesson in the next part; the reason it is here is that a delegated task is precisely where you need it.

Reaching back for the original host

When the task genuinely needs a value that the delegated context does not give you implicitly, hostvars is the explicit route in both directions:

- name: Register the new node with the config service
  ansible.builtin.uri:
    url: "https://cfg.example.com/nodes/{{ inventory_hostname }}"
    method: PUT
    body_format: json
    body:
      address: "{{ hostvars[inventory_hostname]['ansible_default_ipv4']['address'] }}"
      role: "{{ hostvars[inventory_hostname]['node_role'] }}"
  delegate_to: "{{ config_service_host }}"

Here hostvars[inventory_hostname] is doing two jobs. It is explicit about intent — a reader can see this value belongs to the app server, not to the config host — and it is the form upstream requires when the value feeds a connection or become option. Writing it that way everywhere in delegated tasks costs a few characters and removes an entire category of “which host is this?” review comment.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task delegated to lb1 renders host: "{{ inventory_hostname }}" in its module arguments. The play targets web1 through web20. What value does the module receive on the third iteration?

  2. Q2. Inventory gives web1 ansible_port 22 and lb1 ansible_port 2222. A task on web1 is delegated to lb1. Which port does the SSH connection use, and what does {{ ansible_port }} render inside the task?

  3. Q3. A play over 60 app servers has one task delegated to a single production load balancer. Which statements about blast radius are correct? Select all that apply.

  4. Q4. Inside a task delegated to lb1, {{ ansible_distribution }} reports the operating system of lb1.

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