This is the third post in our Ansible fundamentals series, building on inventory and playbooks and roles.
Every Ansible user eventually hits the same confusing moment: a variable has a value they didn't expect, set somewhere they didn't think to look. Ansible pulls variables from a genuinely large number of sources, and understanding the precedence between them is the difference between confidently tracing a value to its source and guessing.
Where variables actually come from
Roughly in order from lowest to highest precedence (Ansible's real list has more granularity than this, but these are the sources that account for the vast majority of real confusion):
- Role
defaults/(the lowest precedence — meant to be overridden) - Inventory group and host variables
- Playbook
vars:blocks - Role
vars/main.yml(distinct fromdefaults/— higher precedence, not meant to be casually overridden) - Task-level
vars: - Extra vars passed via
-eon the command line (the highest precedence — always wins)
ansible-playbook -i inventory.ini site.yml -e "nginx_worker_connections=8192"That single -e flag overrides every other source of nginx_worker_connections, no matter where else it was set — which is exactly why -e is the right tool for a genuinely one-off override, and the wrong tool for anything meant to be a durable, reviewable configuration decision. A value that matters for a specific environment belongs in inventory group variables, not in a flag someone has to remember to type correctly every time.
Inventory variables in practice
# inventory.ini
[web]
web1.internal
web2.internal
[web:vars]
nginx_worker_connections=2048
[production:children]
web
[production:vars]
environment=production
monitoring_enabled=true[web:vars] applies to every host in the web group; [production:vars] applies to the whole production group-of-groups. This is usually the right layer for "this value differs by environment or by role" — it's visible, versioned alongside the rest of the inventory, and doesn't require touching the playbook or role itself.
For anything with more structure than flat key-value pairs, YAML inventory files (or group_vars/ and host_vars/ directories) are the more common real-world pattern:
inventory/
hosts.ini
group_vars/
web.yml
production.yml
host_vars/
web1.internal.yml
Ansible loads group_vars/<groupname>.yml and host_vars/<hostname>.yml automatically, purely by filename convention — no explicit include required.
Facts: variables Ansible discovers, not ones you set
Before running any tasks, Ansible gathers facts — information about each target host, collected automatically at the start of a play.
- name: Show some facts
hosts: web
tasks:
- name: Print OS family
ansible.builtin.debug:
msg: "{{ ansible_facts['os_family'] }} on {{ ansible_facts['distribution'] }}"ok: [web1.internal] => {
"msg": "Debian on Ubuntu"
}
Facts are what make a single playbook safely portable across a mixed fleet:
- name: Install web server package
ansible.builtin.package:
name: "{{ 'httpd' if ansible_facts['os_family'] == 'RedHat' else 'nginx' }}"
state: presentOne task, correct on both Ubuntu and RHEL hosts, because the fact-gathering step already told Ansible which distribution family it's talking to. Fact gathering does add a small amount of time to every play — for a large fleet where you know facts aren't needed, gather_facts: false at the play level skips it, but that's an optimization to reach for once it's actually a measured bottleneck, not a default.
Jinja2 templates: variables and facts, rendered into real files
We used ansible.builtin.template in the previous post without fully explaining it — this is where it earns its keep. A .j2 file is a plain-text file (a config file, in the common case) with Jinja2 expressions embedded directly in it.
{# templates/app.conf.j2 #}
server {
listen {{ app_port | default(8080) }};
server_name {{ ansible_facts['hostname'] }};
{% if environment == "production" %}
access_log /var/log/app/access.log combined;
{% else %}
access_log /var/log/app/access.log;
{% endif %}
upstream backend {
{% for host in groups['app_servers'] %}
server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:{{ app_port | default(8080) }};
{% endfor %}
}
}This one template demonstrates most of what Jinja2 templating is actually used for in Ansible:
{{ variable | default(value) }}— a filter providing a fallback if the variable isn't set, avoiding a hard failure for genuinely optional configuration.{% if %} / {% else %}— conditional blocks, commonly used exactly like this: different config for production vs. everything else.{% for %}— looping, here overgroups['app_servers'](every host in that inventory group) to generate a load-balancer upstream block automatically, without hardcoding a single hostname.hostvars— a way to reach into another host's facts and variables from within the current host's template. This is how one server's config file can correctly reference the current IP addresses of several other servers, generated fresh on every run.
What to actually remember from this post
- Variable precedence has a real, learnable order —
-ealways wins, role defaults always lose; everything else sits somewhere in between based on how specifically it's scoped. - Facts are discovered, not declared — they're what lets one playbook behave correctly across a genuinely mixed fleet.
group_vars/andhost_vars/are the usual right place for environment- and host-specific values, not scattered-eflags or inlinevars:blocks.- Jinja2 templates turn variables and facts into real config files — and
hostvarsis what lets one host's template reference another host's current state.
Next in the series: Ansible Vault: Managing Secrets Safely, where we cover what happens when one of these variables is a password.
