AnsibleVIII · Modules and the Module ModelThe module model
Not everything runs on the target
What you'll learn
- Distinguish an action plugin from the module it eventually invokes
- Predict which side of the connection a given piece of work happens on
- Explain why a Jinja lookup reads the controller filesystem, not the target
- Diagnose a "file not found" that names a path which exists on the target
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 module contract says a module runs on the target. That is true, and several of the things you write in a task file are not modules.
ansible.builtin.template looks exactly like a module in a playbook. It
has options, it reports changed, it appears in ansible-doc -l. But
the Jinja rendering happens on your controller, using your controller’s
filesystem, before anything is sent anywhere. The thing that does that is
an action plugin, and it is what the task actually invokes.
This one distinction explains a whole family of otherwise unrelated confusions.
The two-layer picture
A task with a module name goes through two layers:
- The action plugin, which runs on the controller. It may do preparatory work — render a template, read a local file, compute a checksum, decide whether to call a module at all.
- The module, which is packaged, copied to the target, executed there, and returns JSON.
Most tasks use the generic normal action plugin, which does nothing
interesting: it packages the module and runs it. Those tasks are the
simple case, and the mental model “the module runs on the target” is
completely correct for them.
Twenty-nine modules in ansible-core 2.21 declare an action attribute,
meaning they have a bespoke action plugin doing controller-side work
first. These are the ones that break the simple model:
add_host assemble assert async_status command
copy debug dnf fail fetch
gather_facts group_by include_vars normal package
pause raw reboot script service
set_fact set_stats shell template unarchive
uri validate_argument_spec wait_for_connection
$ ansible-doc ansible.builtin.command | grep -A1 'note:' * note: This module has a corresponding action plugin.Illustrative output
The action attribute in the attributes table says the same thing more
precisely, with a support level:
action: Indicates this has a corresponding action plugin so some parts of the options can be executed on the controller.
Where the work happens, module by module
| Task | Controller side | Target side |
|---|---|---|
template | Reads src, renders Jinja with the host’s variables | Writes the rendered file, sets ownership and mode |
copy | Reads src, computes a checksum | Compares checksum, writes if different |
fetch | Writes the retrieved file into dest | Reads the file, returns it |
assemble | Concatenates the fragments in src | Writes the assembled file |
script | Reads the script file | Receives it, executes it, returns rc and output |
unarchive | Reads the archive when it is local | Unpacks it |
debug, assert, fail, set_fact | Everything | Nothing. No connection is made. |
The last row is the sharpest one. debug, assert, fail and
set_fact never touch the target at all.
The src that must exist on the controller
The most common consequence in day-to-day work is that template,
copy, script and assemble resolve src on the controller.
Watch what happens when the file is missing:
$ ansible-playbook ctrl.yml --checkTASK [Template a source that does not exist on the controller] *****************
fatal: [localhost]: FAILED! => {"changed": false, "msg": "Could not find or access 'templates/nowhere.conf.j2'\nSearched in:\n\t/opt/estate/templates/nowhere.conf.j2\n\t/opt/estate/templates/nowhere.conf.j2 on the Ansible Controller."}Illustrative output
“on the Ansible Controller” is in the error text verbatim. Ansible is being explicit because this is the misdiagnosis it expects: the reader sees a path, SSHes to the target, finds the file present, and concludes Ansible is broken.
The mirror image is fetch, where dest is on the controller. A fetch
that fails with a permission error is usually a permission error on your
workstation, not on the fleet.
Lookups read the controller too
lookup() is a plugin family that executes on the controller during
templating. So:
- name: This reads a file on the CONTROLLER
ansible.builtin.debug:
msg: "{{ lookup('ansible.builtin.file', '/etc/hostname') }}"
- name: This reads a file on the TARGET
ansible.builtin.slurp:
src: /etc/hostname
Two tasks, the same path, two different machines. The first prints your controller’s hostname for every host in the play — identically, because it is the same file read once per templating pass.
lookup('pipe', ...) and lookup('env', ...) are the same story and
more dangerous: pipe runs a shell command on the controller. A task
that reads lookup('pipe', 'hostname') inside a fleet-wide play executes
hostname on your workstation, once per host, and gives you your own
hostname 300 times.
delegate_to: localhost is a third thing
Worth naming, because it is easy to conflate with the above.
delegate_to: localhost does not make a task controller-side in the
action-plugin sense. It changes which host the task connects to, so the
module still runs — packaged, copied, executed — but on the controller
acting as a target.
The distinguishing detail: variables still come from the original host in
the play loop. A task delegated to localhost inside a play over 300 hosts
runs 300 times, on your controller, each with a different host’s
variables. That is usually what you want for something like an API call
per host, and it is a common way to accidentally run something 300 times
that you meant to run once. run_once: true is the pairing that fixes
it.
Knowledge check
Knowledge check · 4 questions
Q1. A template task fails with "Could not find or access templates/app.conf.j2". You SSH to the target and the file is there. What is wrong?
Q2. Which of these do work on the controller rather than, or before, the target? Select all that apply.
Q3. A pre-flight play whose only task is ansible.builtin.debug will report unreachable hosts correctly, because Ansible checks connectivity before running any task.
Q4. A task with delegate_to: localhost sits in a play over 300 hosts. How many times does it run, and with whose variables?
Passing score: 75%. Answers are checked in this browser.