Ansible Variable Precedence
- Why precedence matters
- The full precedence chain
- Where to put your variables
- Role defaults (lowest priority)
- Inventory and group_vars
- host_vars (beat group_vars)
- Play vars and task vars
- Extra vars (highest priority)
- Inspecting variable values
- Common confusion scenarios
- set_fact, registered vars, include_vars
- combine filter and hash_behaviour
- Vault variable file pattern
Why precedence matters
Ansible resolves variables from many sources at once. When the same variable name appears in multiple places, one value wins. Understanding which source wins is the key to debugging "why does the config have the wrong value?"
The rule is simple: higher in the list = wins. But knowing the list by heart is what separates someone who debugs quickly from someone who guesses.
The full precedence chain
From lowest (easiest to override) to highest (hardest to override):
- Role defaults —
roles/rolename/defaults/main.yml - Inventory vars — variables set directly in
hosts.inion a group or host line - group_vars/all — applies to every host
- group_vars/groupname — applies to one group
- host_vars/hostname — applies to one host
- Play vars —
vars:block in the playbook play - Role vars —
roles/rolename/vars/main.yml(note: higher than group_vars) - Task vars —
vars:block inside a specific task - Extra vars —
-e "varname=value"on the command line
roles/nginx/vars/main.yml has higher precedence than group_vars/webservers.yml. Put variables users should be able to override in defaults/, not vars/.
Where to put your variables (decision guide)
- Role defaults (
defaults/main.yml) — sensible fallbacks, things callers should be able to override - group_vars/all.yml — values that apply everywhere: log server, NTP servers, environment name
- group_vars/groupname.yml — values specific to a service tier: nginx settings for webservers, mail config for mailservers
- host_vars/hostname.yml — things genuinely unique to one host: a specific IP, a one-off override
- Role vars (
vars/main.yml) — internal package/service names that should never be user-overridden - Extra vars (
-e) — one-off overrides for a single run; testing; emergency changes
Role defaults (lowest priority)
Defined in roles/rolename/defaults/main.yml. These are the documented public interface of your role. Everything a caller can configure should have a default here.
# roles/nginx/defaults/main.yml
---
nginx_port: 80
nginx_worker_processes: auto
nginx_client_max_body_size: 1m
enable_tls: false
Because defaults are the lowest priority, any group_vars, host_vars, or -e override will silently win. This is intentional — defaults should be safe starting points, not requirements.
Inventory and group_vars
The most common place to set production values is in group_vars/. These override role defaults.
# inventories/production/group_vars/all.yml
---
# Overrides the role default of 0.pool.ntp.org
chrony_servers:
- ntp1.internal.example.com
- ntp2.internal.example.com
# inventories/production/group_vars/webservers.yml
---
# Overrides nginx defaults for this group
nginx_port: 443
nginx_client_max_body_size: 64m
enable_tls: true
If a host belongs to multiple groups and both group_vars files define the same variable, the result is not simply alphabetical — it depends on inventory structure, group depth, and ansible_group_priority. This is a common source of subtle bugs. Avoid it by using host_vars, restructuring groups, or setting ansible_group_priority explicitly on the group you want to win.
host_vars (beat group_vars)
host_vars/hostname.yml beats all group_vars. Use it for genuine per-host differences.
# inventories/production/host_vars/web01.yml
---
# This host runs on a non-standard port due to a legacy app
nginx_port: 8443
nginx_server_name: web01.example.com
Play vars and task vars
Variables defined directly in a play or task. Useful for one-off runs or when you need a value that is not environment-specific.
- name: Deploy with custom port this run
hosts: webservers
vars:
nginx_port: 9090 # overrides group_vars for this play only
roles:
- nginx
- name: Override only for this one task
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: nginx -t -c %s
vars:
nginx_port: 9090 # only affects this task's variable context
Extra vars (highest priority)
Passed on the command line with -e. Nothing overrides them. Use for emergency changes or testing without editing files.
# Override one variable for this run
ansible-playbook site.yml -e "nginx_port=9090"
# Override multiple
ansible-playbook site.yml -e "nginx_port=9090 enable_tls=false"
# Pass a YAML file
ansible-playbook site.yml -e @overrides.yml
Inspecting variable values
The fastest way to see what value Ansible will actually use for a variable on a specific host:
# Print the value of a variable for all hosts
ansible all -i inventories/production/hosts.ini -m debug -a "var=chrony_servers"
# Print for one host
ansible web01 -i inventories/production/hosts.ini -m debug -a "var=nginx_port"
# Print all variables for a host (verbose — lots of output)
ansible web01 -i inventories/production/hosts.ini -m debug -a "var=hostvars[inventory_hostname]"
Or add a debug task to a playbook temporarily:
- name: DEBUG — show variable value
ansible.builtin.debug:
msg: "nginx_port is {{ nginx_port }}, enable_tls is {{ enable_tls }}"
Common confusion scenarios
Variable appears correct in group_vars but role uses wrong value
Most likely cause: the role has the variable in vars/main.yml (not defaults/). Role vars beat group_vars. Move it to defaults.
host_vars value is not being picked up
Check the file is in the right place: inventories/production/host_vars/EXACT_HOSTNAME.yml. The filename must match the host as written in the inventory exactly — including any domain suffix.
Extra vars override everything unexpectedly
Someone ran the playbook with -e previously in a pipeline. Check CI/CD job definitions for hardcoded -e flags that might be overriding intended values.
Variable is undefined even though I set it
Check for typos in variable names. YAML is case-sensitive — Nginx_Port and nginx_port are different variables. Run the debug module to confirm the actual name Ansible sees.
set_fact, registered vars, include_vars
These three sources sit above the standard precedence table and cause the most debugging surprises.
set_fact — create or override a variable mid-play
- name: Build the config path from variables
ansible.builtin.set_fact:
config_path: "/etc/{{ app_name }}/{{ env }}.conf"
# The variable is now available for every subsequent task in the play
- name: Template the config
ansible.builtin.template:
src: app.conf.j2
dest: "{{ config_path }}"
set_fact variables persist for the rest of the play (across roles). They beat host_vars and group_vars but lose to -e extra vars.
Registered variables — capture task output
- name: Check if config file exists
ansible.builtin.stat:
path: /etc/myapp/myapp.conf
register: conf_stat
- name: Only proceed if file exists
ansible.builtin.debug:
msg: "Config size is {{ conf_stat.stat.size }} bytes"
when: conf_stat.stat.exists
The register: output structure depends on the module. Use - debug: var=conf_stat to inspect its full structure.
include_vars — load a variable file at runtime
# Load a file based on OS family
- name: Load OS-specific variables
ansible.builtin.include_vars:
file: "vars/{{ ansible_os_family }}.yml"
# Load a directory of variable files
- name: Load all vars from directory
ansible.builtin.include_vars:
dir: vars/
extensions: [yml]
Useful for OS-specific defaults. include_vars is dynamic, so it can use facts gathered earlier in the play.
vars_files and vars_prompt
# vars_files — static, loaded at play parse time
- name: Configure app
hosts: appservers
vars_files:
- vars/common.yml
- vars/{{ env }}.yml # environment-specific overrides
# vars_prompt — interactive input (not suitable for CI/CD)
- name: Deploy with confirmation
hosts: prod
vars_prompt:
- name: deploy_version
prompt: "Enter the version to deploy"
private: false
combine filter and hash_behaviour
The combine filter merges dictionaries, which solves the "I want group_vars defaults but allow host_vars to override individual keys" problem.
# In group_vars/all/vars.yml — base defaults
nginx_settings:
worker_processes: auto
keepalive_timeout: 65
server_tokens: "off"
# In host_vars/web01.yml — override just one key
nginx_settings_override:
keepalive_timeout: 30
# In the role task — merge at runtime
- name: Build merged nginx settings
ansible.builtin.set_fact:
nginx_merged: "{{ nginx_settings | combine(nginx_settings_override | default({})) }}"
Without combine, defining any key in nginx_settings at a higher-precedence source replaces the entire dict, losing all the defaults.
hash_behaviour = merge setting in ansible.cfg that auto-merges dicts, but this is not recommended — it changes behaviour globally and makes playbooks harder to reason about. Use combine explicitly instead.
Vault variable file pattern
The standard pattern for secrets in an Ansible repo is to split group_vars into a plain file and an encrypted vault file.
Directory layout
group_vars/
all/
vars.yml # Plain — references vault_ variables
vault.yml # Encrypted with ansible-vault
vault.yml (encrypted)
# Contents before encryption:
vault_db_password: "s3cr3tP@ssword"
vault_api_key: "abc123xyz"
# Encrypt the file
ansible-vault encrypt group_vars/all/vault.yml
# Edit in place (decrypts, opens editor, re-encrypts on save)
ansible-vault edit group_vars/all/vault.yml
vars.yml (plain — references vault vars)
# Use vault_ prefix by convention so you know which vars are secrets
db_password: "{{ vault_db_password }}"
api_key: "{{ vault_api_key }}"
The plain file is safe to read in code review. Secrets never appear unencrypted in version control. Your templates and tasks reference db_password (not vault_db_password) — vault is an implementation detail.
Running playbooks with Vault
# Prompt for password at runtime
ansible-playbook site.yml --ask-vault-pass
# Use a password file (for CI/CD — store the file securely)
ansible-playbook site.yml --vault-password-file ~/.vault_pass.txt