Ansible Deep Dive Part 6: Variable Precedence and Ansible Vault — Secrets Done Right

Part 4 deferred the full variable precedence order to avoid tangling it with Vault. Here’s both, because in practice they’re usually debugged together — “why is this variable not the value I put in group_vars” is very often actually “a vaulted value further up the chain is winning.”

The full precedence ladder (lowest to highest)

Ansible’s official order, condensed to the levels you’ll actually hit in practice:

  1. Role defaults/main.yml — lowest
  2. Inventory file or script group vars
  3. group_vars/all
  4. group_vars/<group>
  5. Inventory host_vars/<host>… and host_vars/ directory files (directory takes precedence over inline)
  6. Host facts
  7. Play vars:
  8. Role vars/main.yml
  9. Block vars, task vars (innermost scope wins)
  10. set_fact / registered variables
  11. -e / --extra-vars on the command line — highest, always wins

The two rules worth memorizing rather than the whole table: role defaults are the floor, meant to be overridden by everything, and -e is the ceiling, overriding everything else no matter where else it’s set. Everything in between follows a rough “more specific beats less specific, and things set later at runtime beat things set earlier in files” logic, but when in doubt, -e on the command line is the reliable way to force a value during testing without editing any file.

Debugging a wrong value: ansible-inventory --list (Part 2) shows the final merged result; adding ansible.builtin.debug: var=my_variable as a task shows it at that specific point in a specific play, which matters because set_fact and task-scoped vars can change a value partway through a run.

Ansible Vault: encryption for anything that shouldn’t be plaintext in git

Device passwords, API tokens, TLS private keys, database credentials — all of it needs to live somewhere, and “in a YAML file the rest of the team can read on GitHub” isn’t acceptable for any of it. Vault encrypts values (or whole files) with AES256, transparently decrypting them at playbook run time given the right password.

Encrypting an entire file

ansible-vault create group_vars/all/vault.yml
# opens $EDITOR, encrypts on save
# group_vars/all/vault.yml (plaintext shown here; encrypted on disk)
vault_db_password: "correct-horse-battery-staple"
vault_fortigate_admin_password: "S3cr3t!"
ansible-vault edit group_vars/all/vault.yml    # decrypt, edit, re-encrypt
ansible-vault view group_vars/all/vault.yml    # read-only, no edit
ansible-vault encrypt existing_plain_file.yml  # encrypt something already on disk

The vault_ prefix pattern

Note the vault_ prefix on the variable names above — this is deliberate convention, not a requirement. The actual variable a task references is the un-prefixed name, defined separately in a non-encrypted file:

# group_vars/all/vars.yml (plaintext, safe to read/diff/grep freely)
db_password: "{{ vault_db_password }}"
fortigate_admin_password: "{{ vault_fortigate_admin_password }}"

Tasks reference db_password, never vault_db_password directly. This means the entire codebase stays readable and greppable in plaintext — you can git diff and code-review changes to which variable is used where — with only the actual secret values locked away in the encrypted file. This is close to the single most valuable convention in this whole post; skipping it is the difference between a repo you can code-review normally and one where every diff to a vault file is an opaque encrypted blob.

Encrypting a single string, inline

ansible-vault encrypt_string 'S3cr3t!' --name 'fortigate_admin_password'

produces a !vault |-tagged block you can paste directly into an otherwise-plaintext YAML file — useful for encrypting just one sensitive value inside a file where everything else should stay readable, rather than vaulting the whole file.

Running a playbook that needs the vault password

ansible-playbook site.yml --ask-vault-pass          # prompt interactively
ansible-playbook site.yml --vault-password-file ~/.vault_pass.txt   # read from file
ansible-playbook site.yml --vault-password-file ~/.vault_pass.sh    # or a script that prints one

The vault password itself must never be committed — it’s the master key to everything else. I keep it in a password manager and pull it via a small script referenced by --vault-password-file, and in CI it comes from the pipeline’s own secrets store, injected as an environment variable that a tiny wrapper script echoes out. ansible.cfg can also point at a password file by default:

[defaults]
vault_password_file = ~/.vault_pass.sh

Vault IDs: more than one secret tier

Real environments usually have more than one class of secret with different access — say, a “dev” vault everyone on the team can decrypt, and a “prod” vault only a smaller group holds the password for:

ansible-vault create --vault-id dev@prompt group_vars/dev/vault.yml
ansible-vault create --vault-id prod@prompt group_vars/prod/vault.yml
ansible-playbook site.yml --vault-id dev@~/.vault_pass_dev --vault-id prod@~/.vault_pass_prod

Each vault ID can have its own password source, and Ansible tries each in turn against encrypted content until one works — letting a junior engineer run playbooks against dev freely while genuinely lacking the ability to decrypt anything tagged prod, enforced by the tool rather than by trust.

Part 7 shifts from correctness to speed: ansible.cfg, connection pipelining, fact caching, and the strategy plugins that determine whether a hundred-host playbook run takes ninety seconds or fifteen minutes.