Skip to main content
RunBook Academy

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

Delegating to a host that is not in inventory

Advanced⏱ ~24 minansible-core

What you'll learn

  • State exactly what a non-inventory delegate does and does not inherit
  • Explain why group_vars/all reaches the delegate connection when host_vars do not
  • Use add_host to create a real in-memory inventory entry when one is genuinely needed
  • Argue for putting a delegated-to host in inventory rather than naming it inline

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.

delegate_to accepts anything the connection plugin can dial. It does not have to be a host in your inventory:

- name: Tell the balancer to drain this host
  ansible.builtin.uri:
    url: "http://192.0.2.250:8404/drain/{{ inventory_hostname }}"
    method: POST
  delegate_to: 192.0.2.250

That runs. Upstream is unusually direct about it:

“Although you can delegate_to a host that does not exist in inventory (by adding an IP address, DNS name or whatever requirement the connection plugin has), doing so does not add the host to your inventory and might cause issues.”

“Might cause issues” is doing a lot of work in that sentence. This lesson measures what actually happens, because the answer is not the simple one people assume, and the part that surprises is the part that will bite you.

What the delegate does not get

Two hosts’ worth of evidence, from a play targeting web1 and delegating to 192.0.2.250 — an address in the documentation range, so nothing real was contacted:

Read-only / Safethe delegate is not in inventory in any sense
$ ansible-playbook -i outside.ini outside.yml -T 5
TASK [Delegate to a host that is not in inventory] *****************************
fatal: [web1 -> 192.0.2.250]: UNREACHABLE! => {"changed": false, "msg": "Task failed:
Failed to connect to the host via ssh: ssh: connect to host 192.0.2.250 port 2200:
Connection timed out", "unreachable": true}

TASK [Is there a hostvars entry for it] ****************************************
ok: [web1] => {
  "msg": "in hostvars: False | groups.all = ['web1']"
}

'192.0.2.250' in hostvars is False, and groups['all'] contains only web1. The delegate exists for the duration of the connection and nowhere else. Concretely, that costs you:

  • No host_vars. There is no inventory entry to attach them to.
  • No group_vars from any real group. It is in no groups. Not all in the groups sense, not loadbalancers, not anything.
  • No facts, ever. hostvars['192.0.2.250']['ansible_distribution'] raises undefined. Nothing gathered facts for it, and delegate_facts: true would need somewhere to put them.
  • No membership in groups, so it is invisible to groups['loadbalancers'] | first and to every pattern-based check you might use to reason about the fleet.
  • No appearance in ansible-inventory. Your inventory graph — the blast-radius map this course keeps insisting on — does not know this machine is touched by your automation.

That last one is the important one, and it is the reason this pattern is discouraged rather than merely inconvenient.

What it does get, and this is the surprise

Look at the port in that error message: 2200. The SSH default is 22. Something supplied 2200, and it was not the delegate’s own inventory entry, because it has none.

Two runs isolate it. The same delegation, the same address, with ansible_port placed in two different scopes:

Read-only / Safehost_vars do not follow; group_vars/all does
$ ansible-playbook -i outside3.ini outside3.yml -T 4  # then outside4.ini
#### A: ansible_port as a HOST var on web1 only ####
fatal: [web1 -> 192.0.2.250]: UNREACHABLE! => {"msg": "... ssh: connect to host
192.0.2.250 port 22: Connection timed out", "unreachable": true}

#### B: ansible_port in [all:vars] ####
fatal: [web1 -> 192.0.2.250]: UNREACHABLE! => {"msg": "... ssh: connect to host
192.0.2.250 port 2200: Connection timed out", "unreachable": true}

With ansible_port=2999 set on web1 as a host variable, the delegate connected on 22 — the plugin default. The current host’s connection settings do not follow the delegate, which is consistent with everything in lesson 2.

With ansible_port=2200 in [all:vars], the delegate connected on 2200.

So the precise rule for a non-inventory delegate:

It has no hostvars entry and no group membership, but variables defined for the all group are still in scope when its connection is built.

add_host: the deliberate version

When a host genuinely is not known until the run — a VM you just provisioned, a container whose address came from an API — the honest tool is ansible.builtin.add_host, which creates a real in-memory inventory entry for the rest of the run.

Read-only / Safeadd_host creates something that actually exists
$ ansible-playbook -i outside.ini outside.yml -T 5
TASK [add_host creates a real in-memory entry] *********************************
ok: [web1]

TASK [Now it exists] ***********************************************************
ok: [web1] => {
  "msg": "in hostvars: True | user=proxyops | groups=['all', 'ungrouped', 'web', 'loadbalancers']"
}

The entry is complete in the ways that matter: it is in hostvars, it is in a group, and the group appears in groups.

Configuration changeregistering a just-provisioned host
- name: Register the new proxy for the rest of this run
ansible.builtin.add_host:
  name: "lb-{{ provision_result.instance.id }}"
  ansible_host: "{{ provision_result.instance.private_ip }}"
  ansible_user: proxyops
  ansible_port: 2222
  groups: loadbalancers
run_once: true
changed_when: false

- name: Now it can be delegated to by name, like anything else
ansible.builtin.uri:
  url: "http://{{ hostvars[groups['loadbalancers'][0]]['ansible_host'] }}:8404/drain/{{ inventory_hostname }}"
  method: POST
delegate_to: "{{ groups['loadbalancers'][0] }}"

Three things about add_host worth holding on to:

  1. It is in-memory and per-run. Nothing is written to an inventory file. The next ansible-playbook invocation knows nothing about it, which is correct for an ephemeral host and wrong for a permanent one.
  2. Variables you pass become host variables on the new entry, so you can set connection settings that are true for that machine instead of inheriting group_vars/all.
  3. It bypasses the host loop implicitly — the module is documented as acting on the in-memory inventory, not on a target — but people still pair it with run_once to avoid the noise of running it once per host. Given the previous two lessons, note that under serial the run_once would fire once per batch; adding the same host repeatedly is idempotent, so this is one of the cases where per-batch execution is harmless.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task delegates to the bare address 192.0.2.250, which is not in inventory. The repository has group_vars/all.yml setting ansible_user, ansible_port and ansible_ssh_private_key_file for the fleet. What settings does the delegated connection use?

  2. Q2. What does ansible.builtin.add_host create?

  3. Q3. What does a non-inventory delegate lack, compared with a host that has a real inventory entry? Select all that apply.

  4. Q4. Adding delegate_facts: true to a task delegated to a bare address gives you a usable fact set for that machine.

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