Ansible lookup('file', ...) Relative Paths Resolve Against playbook_dir, Not Your Shell's cwd¶
Problem¶
A task reads a file with a path relative to the repo root, expecting it to resolve the same way a shell command run from that directory would:
- name: Read this host's checked-in config
ansible.builtin.set_fact:
found_value: "{{ lookup('file', 'roles/myrole/files/hosts/' + inventory_hostname + '/some.conf', errors='ignore') }}"
Running ansible-playbook some.yml from the repo root, this either:
- Returns an empty/
Nonevalue witherrors='ignore'silently swallowing the real problem, or - Without
errors='ignore', fails withUnable to access the file '<path>': File not found. Use -vvvvv to see paths searched.
— even though the file genuinely exists at that path relative to the repo root, and even
though cd <repo-root> && ls <path> confirms it.
Root cause¶
The file lookup plugin resolves a relative path against the directory containing the
playbook file that is currently executing (playbook_dir), not the shell's current
working directory the ansible-playbook command was invoked from, and not any role's own
directory either, unless the lookup happens to run from a task file in that role whose
directory is also playbook_dir for that run.
This is easy to miss because the two usually coincide: cd ~/MajorAnsible && ansible-playbook foo.yml
makes playbook_dir equal to the shell's cwd, so a relative path "just works" by
accident. It stops working the moment that assumption breaks — e.g. a quick throwaway test
playbook written to /tmp for debugging, or invoking a playbook by absolute path from a
different directory.
Confirmed with a minimal reproduction (2026-09-30)¶
# /tmp/test.yml (playbook_dir = /tmp)
- hosts: localhost
gather_facts: false
tasks:
- debug:
msg: "{{ lookup('file', 'roles/fail2ban/files/hosts/majorlinux/jail.local') }}"
Run as ansible-playbook /tmp/test.yml from inside ~/MajorAnsible — fails with "File
not found", because playbook_dir is /tmp, not the shell's cwd (~/MajorAnsible).
The same lookup succeeds immediately once the relative path is replaced with
playbook_dir + '/roles/fail2ban/files/hosts/majorlinux/jail.local', or once the test
playbook is moved into ~/MajorAnsible itself (making playbook_dir match the intended
root).
Fix¶
Prefix the relative path with the playbook_dir special variable so resolution doesn't
depend on where the playbook happens to live relative to the shell's cwd:
- name: Read this host's checked-in config
ansible.builtin.set_fact:
found_value: >-
{{
lookup('file', playbook_dir + '/roles/myrole/files/hosts/' + inventory_hostname + '/some.conf', errors='ignore')
}}
This makes the lookup resolve the same way regardless of the shell's current directory — important for a shared repo where playbooks are sometimes run with a full path from outside the repo, or copied into a scratch location for a quick test.
Where this came up in MajorAnsible¶
Hit while fixing verify_fail2ban.yml's expected_sender check (2026-09-30, see
Forgejo-CI): a new task used lookup('file', 'roles/fail2ban/files/hosts/' +
inventory_hostname + '/jail.local', errors='ignore') to read each host's real checked-in
config instead of comparing against a guessed constant. It silently returned nothing on
every host (errors='ignore' masked the real "file not found" error) until a throwaway
test playbook written to /tmp reproduced the failure directly and isolated it to
playbook_dir vs. the shell's cwd. Prefixing with playbook_dir fixed it immediately;
the fix shipped in commit b8abbd0.
See also ansible-regex-search-set-fact-capture-group for a second, unrelated gotcha hit in the same fix.
Quick checks if this still misbehaves¶
- Don't debug a
lookup('file', ...)path issue witherrors='ignore'still in place — it swallows exactly the error message (File not found. Use -vvvvv to see paths searched.) that would otherwise point straight at the problem. Remove it temporarily while diagnosing. - A throwaway test playbook's own location matters. Writing a quick reproduction to
/tmpto isolate a lookup/path issue works, but remember that playbook'splaybook_diris/tmptoo — a path that "should" resolve relative to the real repo won't, in the test, for the same reason it didn't in the original task. role_pathis the equivalent special variable when the lookup lives inside a role's own tasks and should resolve relative to that role's directory rather than the top-level playbook's.
Related¶
- ansible-regex-search-set-fact-capture-group — a second Jinja/path gotcha found in the same session
- Forgejo-CI — the project file tracking the
verify_fail2ban.ymlfix that prompted this article