AnsibleXXXIII · Delegation and Controller-Side ExecutionDelegation and controller-side execution
delegate_to and the variables that follow it
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
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.
$ ansible-playbook -i deleg2.ini deleg2.yml -T 5TASK [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_connectionand theansible_python_interpreterused 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:
$ ansible-playbook -i deleg3.ini deleg3.ymlTASK [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:
$ ansible-playbook -i deleg.ini deleg.ymlTASK [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 throughhostvars['<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:
- 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: 1Four 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 agroup_vars/web_stage.ymland agroup_vars/web_prod.ymlcan 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: 1because every host in the play delegates to the same machine. Without it,forksapp 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
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?
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?
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.
Q4. Inside a task delegated to lb1, {{ ansible_distribution }} reports the operating system of lb1.
Passing score: 75%. Answers are checked in this browser.