Skip to main content
RunBook Academy

← All labs in Ansible

Lab · intermediate · ~75 min

Lab: The same variable in six places — find the winner, then prove it

C · SimulationB · Nested virtualisation

Objectives

  • Order six real precedence layers by experiment rather than by recall
  • Trace a wrong production value back to the file that set it
  • Demonstrate that ansible-inventory --host omits playbook-adjacent variables unless --playbook-dir is given
  • Explain why role vars beat play vars and what that means for a role interface

Prerequisites

Objective

By the end of this lab you will have determined the relative precedence of six variable layers by experiment — removing one layer at a time and observing what surfaces — rather than by consulting a table you will misremember under pressure. You will also have found the case where ansible-inventory --host, the obvious diagnostic, reports a value the playbook will not use.

Architecture

One inventory, one host, one variable defined six times.

repo/
├── inventories/prod/
│   ├── hosts.yml
│   ├── group_vars/web.yml       app_port: 8000   (inventory group_vars)
│   └── host_vars/node1.yml      app_port: 8002   (inventory host_vars)
├── group_vars/web.yml           app_port: 8001   (playbook group_vars)
├── host_vars/node1.yml          app_port: 8003   (playbook host_vars)
├── roles/app/
│   ├── defaults/main.yml        app_port: 8004   (role defaults)
│   └── vars/main.yml            app_port: 8005   (role vars)
└── site.yml

The port numbers encode nothing — they are labels, chosen so that the value in the output tells you which file it came from without a second lookup. ansible_connection: local on the group means the lab needs no managed nodes.

Requirements

  • A controller with ansible-core 2.21.x. All values below were observed on 2.21.3.
  • No SSH, no credentials, no managed nodes. The play runs debug only.
  • A place to write predictions before running commands. As with the targeting lab, running first and rationalising afterwards teaches nothing.

Scenario

Production is serving on the wrong port. The team’s group_vars/web.yml clearly says app_port: 8001, three people have read it, and the service is not on 8001. Somebody suggests the file is not being loaded. Somebody else suggests a caching problem. Your job is to find where the value actually comes from, and to build the two-command procedure that answers this question in future without a discussion.

Tasks

Task 1: Build the six-layer repository

WORKDIR="$HOME/ansible-precedence-lab"
mkdir -p "$WORKDIR"/{inventories/prod/{group_vars,host_vars},group_vars,host_vars,roles/app/{defaults,vars,tasks}}
cd "$WORKDIR"

# Confirm nothing is redirecting inventory or roles_path
ansible-config dump --only-changed

Write inventories/prod/hosts.yml:

web:
  hosts:
    node1:
  vars:
    ansible_connection: local

Now seed all six layers. Each value is deliberately distinct:

cd "$HOME/ansible-precedence-lab"

echo 'app_port: 8000' > inventories/prod/group_vars/web.yml
echo 'app_port: 8001' > group_vars/web.yml
echo 'app_port: 8002' > inventories/prod/host_vars/node1.yml
echo 'app_port: 8003' > host_vars/node1.yml
echo 'app_port: 8004' > roles/app/defaults/main.yml
echo 'app_port: 8005' > roles/app/vars/main.yml

Write roles/app/tasks/main.yml:

- name: Report the value the role sees
  ansible.builtin.debug:
    msg: "role sees {{ app_port }}"

And two playbooks. site.yml uses no role, so it isolates the four inventory and playbook layers:

# site.yml
- name: Layers without a role
  hosts: web
  gather_facts: false
  tasks:
    - name: Report
      ansible.builtin.debug:
        msg: "no role: {{ app_port }}"
# site-role.yml
- name: Layers including the role
  hosts: web
  gather_facts: false
  roles:
    - app

Task 2: Predict, then peel

Write down your prediction for each row before running anything. Then run site.yml, delete the layer that won, and run again.

StepLayers still presentPredictedActual
1all four non-role layers
2after deleting host_vars/node1.yml
3after deleting inventories/prod/host_vars/node1.yml
4after deleting group_vars/web.yml

Run step 1:

Read-only / Safecontroller
$ ansible-playbook -i inventories/prod/hosts.yml site.yml
TASK [Report] ******************************************************************
ok: [node1] => {
  "msg": "no role: 8003"
}

8003 is host_vars/node1.yml — the playbook-adjacent host_vars, not the inventory-adjacent one. Peel it away:

rm host_vars/node1.yml
ansible-playbook -i inventories/prod/hosts.yml site.yml | grep msg

Then the next, and the next:

rm inventories/prod/host_vars/node1.yml
ansible-playbook -i inventories/prod/hosts.yml site.yml | grep msg

rm group_vars/web.yml
ansible-playbook -i inventories/prod/hosts.yml site.yml | grep msg

The observed sequence is 8003, 8002, 8001, 8000, which orders the four layers, highest first:

  1. playbook-adjacent host_vars/
  2. inventory-adjacent host_vars/
  3. playbook-adjacent group_vars/
  4. inventory-adjacent group_vars/

Restore the three files you deleted before continuing:

echo 'app_port: 8001' > group_vars/web.yml
echo 'app_port: 8002' > inventories/prod/host_vars/node1.yml
echo 'app_port: 8003' > host_vars/node1.yml

Task 3: Add the role and watch the answer invert

Run the role-based playbook:

Read-only / Safecontroller
$ ansible-playbook -i inventories/prod/hosts.yml site-role.yml
TASK [app : Report the value the role sees] *************************************
ok: [node1] => {
  "msg": "role sees 8005"
}

8005 is roles/app/vars/main.yml. Every one of the four layers you just ranked has been overridden by a file inside the role.

Confirm the two directions of the same fact:

# Role defaults lose to inventory: delete vars/main.yml and re-run
mv roles/app/vars/main.yml /tmp/role-vars-main.yml
ansible-playbook -i inventories/prod/hosts.yml site-role.yml | grep msg

# Put it back
mv /tmp/role-vars-main.yml roles/app/vars/main.yml

With vars/main.yml gone the role reports 8003 — the playbook host_vars — because defaults/main.yml at 8004 is the lowest layer of all and loses to everything.

Task 4: The diagnostic that lies

Now the part that costs people an afternoon. ansible-inventory --host is the obvious way to ask “what value does this host have”. Run it from inside the repository:

Read-only / Safecontroller — cwd is the repo root
$ ansible-inventory -i inventories/prod/hosts.yml --host node1
{
  "ansible_connection": "local",
  "app_port": 8003
}

Now run exactly the same command from one directory up:

Read-only / Safecontroller — cwd is the parent directory
$ cd .. && ansible-inventory -i ansible-precedence-lab/inventories/prod/hosts.yml --host node1
{
  "ansible_connection": "local",
  "app_port": 8002
}

The fix is to say which playbook directory you mean:

REPO="$HOME/ansible-precedence-lab"

ansible-inventory -i "$REPO/inventories/prod/hosts.yml" \
  --playbook-dir "$REPO" --host node1

That reports 8003 from anywhere.

Task 5: Find the value in a repository you did not write

The point of the exercise. Have a colleague — or a shell one-liner — hide a seventh definition somewhere, then find it without reading every file:

cd "$HOME/ansible-precedence-lab"

# Every definition of the variable, with its file and line
grep -rn --include='*.yml' --include='*.yaml' 'app_port' . | sort
Read-only / Safecontroller
$ grep -rn --include='*.yml' 'app_port' . | sort
./group_vars/web.yml:1:app_port: 8001
./host_vars/node1.yml:1:app_port: 8003
./inventories/prod/group_vars/web.yml:1:app_port: 8000
./inventories/prod/host_vars/node1.yml:1:app_port: 8002
./roles/app/defaults/main.yml:1:app_port: 8004
./roles/app/vars/main.yml:1:app_port: 8005
./roles/app/tasks/main.yml:3:    msg: "role sees {{ app_port }}"

Six definitions and one use. Rank them with the ladder you built in Tasks 2 and 3, and you have the answer without running anything.

Task 6: Write your ladder

In ladder.md, write the precedence order you observed, highest first, with the value you saw at each step as evidence. Add one sentence per layer saying what it is for. A layer whose purpose you cannot state in one sentence is a layer your repository should probably not be using.

Validation

  • ansible-playbook -i inventories/prod/hosts.yml site.yml reports 8003 with all layers present, and 8002, 8001, 8000 as you delete each winner.
  • ansible-playbook -i inventories/prod/hosts.yml site-role.yml reports 8005.
  • The same command with -e app_port=9999 reports 9999.
  • With roles/app/vars/main.yml moved aside, site-role.yml reports 8003, not 8004 — role defaults are the lowest layer, not a middle one.
  • ansible-inventory -i inventories/prod/hosts.yml --host node1 reports 8003 from the repo root and 8002 from the parent directory.
  • The same command with --playbook-dir "$HOME/ansible-precedence-lab" reports 8003 regardless of where it is run from.
  • ladder.md orders six layers with an observed value beside each.

Expected Outcome

ansible-precedence-lab/
├── group_vars/web.yml
├── host_vars/node1.yml
├── inventories/prod/
│   ├── group_vars/web.yml
│   ├── host_vars/node1.yml
│   └── hosts.yml
├── ladder.md
├── roles/app/
│   ├── defaults/main.yml
│   ├── tasks/main.yml
│   └── vars/main.yml
├── site-role.yml
└── site.yml

You can order six precedence layers from memory because you watched each one surface. You know that ansible-inventory --host needs --playbook-dir to tell the truth about a repository, and you know that a variable in roles/*/vars/ cannot be overridden by anything an operator would try first.

Troubleshooting

Every run reports the same value no matter what you delete. An --extra-vars is set somewhere — check your shell history and any wrapper script. Extra vars beat all six layers and produce exactly this symptom.

group_vars/web.yml has no effect at all. The filename must match the group name exactly. web.yml applies to the group web; webs.yml applies to a group called webs, which does not exist, and Ansible does not warn about a group_vars file that matches nothing.

A group_vars directory instead of a file behaves differently. group_vars/web/ containing several files is valid and all of them load, merged in filename order. That is a useful pattern and a confusing one to debug — grep -rn finds them, a glance at the directory listing may not.

Role defaults appear to win. Then the layer you expected to beat them is not being loaded at all. Confirm with ansible-inventory --list --playbook-dir, and check for a typo in the group name.

ansible-inventory --host shows a variable the play does not have. The reverse of Task 4: you ran it from a directory containing an unrelated host_vars/. Always pass --playbook-dir explicitly when diagnosing.

Cleanup

Everything this lab created lives inside one directory and one temporary file. No system configuration was changed, no host was contacted, and no privilege was escalated.

Step 1. Confirm nothing escaped the working directory:

cd "$HOME/ansible-precedence-lab"
ansible --version | grep 'config file'
ls -la /tmp/role-vars-main.yml 2>/dev/null || echo 'no leftover role vars file'

If /tmp/role-vars-main.yml still exists, Task 3 was interrupted before the mv back. Restore it before deleting anything:

[ -f /tmp/role-vars-main.yml ] && \
  mv /tmp/role-vars-main.yml "$HOME/ansible-precedence-lab/roles/app/vars/main.yml"

Step 2. Keep the ladder — it is the deliverable and you will refer to it:

mkdir -p "$HOME/ansible-lab-deliverables"
cp -a "$HOME/ansible-precedence-lab/ladder.md" \
      "$HOME/ansible-lab-deliverables/precedence-ladder.md"

Step 3. Remove the working directory by absolute path:

rm -rf "$HOME/ansible-precedence-lab"

What You Learned

  • You derived the ladder by peeling layers, and it came out playbook host_vars, inventory host_vars, playbook group_vars, inventory group_vars — with role vars above all four and role defaults below all four.
  • roles/*/vars/main.yml is unreachable from inventory. Only a role parameter, set_fact or --extra-vars overrides it, which is why everything a consumer might tune belongs in defaults/.
  • ansible-inventory --host answers differently depending on your working directory. You reproduced 8003-versus-8002 from the same command, and fixed it with --playbook-dir.
  • A grep -rn for the variable name is a complete search of the file layers, and an incomplete search overall: extra vars, environment and set_fact do not appear in it.
  • Nothing warns you. Six definitions, one winner, zero diagnostics. The only defence is a repository convention about where variables are allowed to live, and the ladder you just wrote is the argument for it.

Deliverables

  • · A six-layer repository with a prediction and a verified result for each removal
  • · A one-page precedence ladder written from your own observations
  • · A note on the ansible-inventory --playbook-dir discrepancy and when it will mislead you

Verification status

Last reviewed
2026-08-11
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.