NovuSpark
All articles

February 13, 2026 · NovuSpark Team

Ansible Variables, Facts, and Templates (Jinja2)

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):

  1. Role defaults/ (the lowest precedence — meant to be overridden)
  2. Inventory group and host variables
  3. Playbook vars: blocks
  4. Role vars/main.yml (distinct from defaults/ — higher precedence, not meant to be casually overridden)
  5. Task-level vars:
  6. Extra vars passed via -e on 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: present

One 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 over groups['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-e always 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/ and host_vars/ are the usual right place for environment- and host-specific values, not scattered -e flags or inline vars: blocks.
  • Jinja2 templates turn variables and facts into real config files — and hostvars is 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.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.