skip to content

In an Ansible playbook targeting a remote server, where does `{{ lookup('file', '/etc/motd') }}` read that file from — the control node or the managed host?

level: middleimportance: nice to knowfreq 32%

answer

  1. templating happens before shipping
  2. the controller's filesystem, always
  3. delegate_to does not move it
  4. a module is what runs remotely
  5. query returns a list, lookup a string

basics

~20 s

From the control node. Every Ansible lookup plugin runs locally, in the controller's Python process, using the controller's filesystem, environment and credentials — never on the managed host, regardless of which host the task targets.

solid answer

~40 s

Lookups always execute on the control node. Templating happens on the controller before the module is shipped to the target, so `lookup('file', '/etc/motd')` reads the controller's `/etc/motd`, `lookup('env', 'HOME')` reads the controller user's environment, and `lookup('password', ...)` writes its file locally. `delegate_to` does not change this — it changes where the *module* runs, not where templating happens. If you actually need remote content, that is a module's job: `slurp` returns a file's contents (base64-encoded) from the managed host, `fetch` copies it back to the controller, or a `command` with `register` gets you the output. The related detail worth knowing is that `lookup()` joins multiple results into a comma-separated string by default, while `query()` (aliased `q()`) always returns a list, which is what you want when feeding a `loop`.

code

yaml · 11 lines
yaml
- name: This reads the CONTROLLER's file
  ansible.builtin.debug:
    msg: "{{ lookup('file', '/etc/motd') }}"

- name: This reads the MANAGED HOST's file
  ansible.builtin.slurp:
    src: /etc/motd
  register: remote_motd

- ansible.builtin.debug:
    msg: "{{ remote_motd.content | b64decode }}"

go deeper

for a junior

Know that lookups run on the machine you launched Ansible from, not on the servers you are configuring, so lookup('file', ...) reads a local file.

for a middle

Explain why: templating is resolved on the controller before the module is shipped, so lookups use the controller's filesystem, environment and credentials — and name slurp or fetch as the way to read remote content.

for a senior

Show the operational consequences: delegate_to does not move lookups, a lookup inside a looped expression can fire once per iteration, and a secret-store lookup makes the controller's identity the blast radius.

for a principal

Own where run-time data is allowed to come from — controller-side lookups against a secret store versus values injected by the pipeline — and who therefore needs which credentials to run the playbook at all.

## Where templating happens Ansible's execution model is: the controller renders the task — resolving variables and Jinja expressions — then packages the module with its arguments, ships it to the target, executes it there, and collects the JSON result. Everything in `{{ }}` is resolved *before* anything reaches the managed host. Lookup plugins are part of that templating step. They are plugin code that runs inside the controller's own Python process, with the controller's filesystem, environment variables, network position and credentials. This is not a quirk; it is the whole point. Lookups exist so the controller can pull data *into* the run from where the controller sits — a file in the repository, a secret store the controller can authenticate to, an environment variable set by CI. ## The consequences people get wrong - `lookup('file', '/etc/motd')` reads the controller's `/etc/motd`. On a run against 200 servers, all 200 tasks get the same content — the controller's. - `lookup('env', 'HOME')` reads the controller user's environment, not the remote user's, and not the `become` user's. - `lookup('pipe', 'git rev-parse HEAD')` runs the command on the controller, in the controller's working directory. - `lookup('password', 'creds/db.txt')` generates and stores the password file on the controller. - `delegate_to: other_host` does not move any of this. Delegation changes the host the module executes against; templating, and therefore lookups, still happen on the controller. That last one is the trap most worth internalising, because delegation feels like it should relocate everything about the task. ## How to actually read remote data Use a module, because modules are the part that runs on the target: ```yaml - name: Read a remote file ansible.builtin.slurp: src: /etc/motd register: motd_raw - name: Decode it ansible.builtin.set_fact: motd: "{{ motd_raw.content | b64decode }}" ``` `slurp` returns the content base64-encoded, hence the `b64decode`. `fetch` is the alternative when you want the file copied back to the controller as a file rather than into a variable. For arbitrary output, a `command` with `register` and `changed_when: false` does the job. ## Lookup versus query ```yaml - ansible.builtin.debug: msg: "{{ lookup('file', 'a.txt', 'b.txt') }}" # one comma-joined string - ansible.builtin.debug: msg: "{{ item }}" loop: "{{ query('file', 'a.txt', 'b.txt') }}" # a real list ``` `lookup()` returns a string by default, joining multiple terms with commas — a legacy behaviour that silently mangles data when you feed it to a loop. `query()` (and its short form `q()`) is equivalent to `lookup(..., wantlist=True)` and always returns a list. Modern `loop:` should be fed by `query()`; the older `with_file:`, `with_items:` style keywords are themselves thin wrappers over the corresponding lookup plugin, which is the historical reason the two concepts are entangled. ## Errors and safety A lookup that fails — missing file, unreachable secret store — is a fatal error for the task by default. Most lookup plugins accept `errors='warn'` or `errors='ignore'` to soften that, and wrapping the expression in Jinja's `default()` will not save you, because the failure happens inside the plugin rather than producing an undefined value. Two operational notes worth carrying into an interview. First, because lookups run on the controller with the controller's credentials, a lookup against a secret store means every operator (and every CI runner) executing that playbook needs that access — the blast radius of a lookup is the controller's identity, not the target's. Second, lookups are evaluated every time the expression is rendered, so a lookup embedded in a variable used inside a large loop can be executed once per iteration; assigning it once with `set_fact` avoids surprising repeated calls to whatever the lookup talks to.

  • Does delegate_to change where a lookup in the same task runs?
    No. Templating — including every lookup — happens on the control node before the module is dispatched anywhere. delegate_to only redirects where the module executes. A task delegated to a load balancer still resolves lookup('file', ...) against the controller's filesystem, which surprises people who expect delegation to relocate the whole task.
  • When should you use query() instead of lookup()?
    Whenever the result feeds a loop or anything expecting a list. lookup() returns a string and joins multiple results with commas, so a two-item result becomes one comma-containing string and the loop runs once. query(), equivalent to lookup with wantlist=True, always returns a list.
  • What is the security implication of pulling secrets with a lookup plugin?
    The lookup runs with the controller's identity, so every operator and CI runner executing that playbook needs access to the secret store, and the value is rendered into the task arguments on the controller. That widens the blast radius from the target host to whoever can run the playbook, which is a deliberate decision rather than an implementation detail.

saying these in an interview costs you the question

  • Thinks lookup('file') reads the managed host's filesystem
  • Expects delegate_to to relocate lookup execution
  • Assumes lookup('env') sees the remote user's environment
  • Feeds lookup() straight into a loop expecting a list
  • Believes default() can rescue a failed lookup

context