Variables, Facts & Jinja2 Templates

Ansible

Chapter 5 · Variables, Facts & Jinja2 Templates

ansible1-3's own warn-box promised this chapter would cover variable precedence properly. It also used {{ item }} and {{ variable | default(...) }} without ever naming what that syntax actually is. Both get resolved here — along with the exact templating mechanism this course's own capstone uses to generate real config files.

Variable Precedence — Resolving Chapter 3's Own Promise

Ansible's real, official precedence order has over twenty distinct levels — more detail than is useful for getting started. The practical version most real playbooks actually need, lowest to highest:

  1. Role defaults (defaults/main.yml) — the lowest priority, meant to be overridden
  2. Inventory variables (group_vars, host_vars, from ansible1-3)
  3. Playbook variables (a play's own vars: block)
  4. Task-level variables / set_fact
  5. Extra vars (-e on the command line) — always wins, no exceptions

A variable set at a higher level on this list always overrides the same variable set at a lower one. -e on the command line is the deliberate escape hatch for "just this one run, use this value, regardless of everything else" — and it always gets the final word.

Facts — Data Ansible Gathers For You

At the start of a play, Ansible automatically gathers facts about each managed host — operating system, IP addresses, memory, CPU count, hostname — unless explicitly disabled. These are available as {{ ansible_facts.xxx }}, or common ones via a shorthand like {{ ansible_distribution }} and {{ ansible_distribution_version }}.

- name: Only run on Debian-family hosts apt: name: ufw state: present when: ansible_os_family == "Debian"

Facts are what make a single playbook safely reusable across genuinely different hosts — the capstone's own target, Debian specifically, is exactly the kind of thing a when: condition on ansible_os_family would check for.

Jinja2 — Ansible's Templating Language

Every {{ }} used since ansible1-2 — variable substitution in a task's arguments, {{ item }} in a loop — is Jinja2, not special Ansible-only syntax. Jinja2 also supports filters: {{ variable | default('fallback') }} provides a safe fallback value when a variable might not be defined, avoiding an "undefined variable" error outright — exactly the pattern used unexplained in ansible1-4's own exercise solutions.

The template Module — Templating Real Config Files

copy transfers a file byte-for-byte, unchanged. template processes a file through Jinja2 first — substituting variables, evaluating conditionals and loops — then copies the resulting, fully rendered output.

# nginx.conf.j2 server { listen {{ http_port }}; server_name {{ server_name }}; }
- name: Deploy nginx config from template template: src: nginx.conf.j2 dest: /etc/nginx/sites-available/default notify: restart nginx

This is exactly the mechanism ansible1-10's own capstone uses to generate real Apache vhost and security-header configuration for ws1's own hardening course — a template file, rendered per-host, replacing what was originally typed by hand.

Conditionals and Loops Inside a Template

{% if ssl_enabled %} listen 443 ssl; {% endif %} {% for header in security_headers %} add_header {{ header.name }} "{{ header.value }}"; {% endfor %}

{% if %}/{% endif %} and {% for %}/{% endfor %} are Jinja2's own template-level conditionals and loops — distinct from the when: and loop: task keywords, which control whether/how a whole task runs, not what appears inside a rendered file. A list of security headers, each with its own name and value, rendered this way is exactly the shape of content ws1's own security-headers chapter covered by hand — now generated.

ModuleWhat it doesProcesses Jinja2?
copyTransfers a file exactly as-isNo
templateRenders {{ }}/{% %} first, then transfers the resultYes
The default filter is a safe, common pattern
{{ my_var | default('fallback') }} substitutes 'fallback' if my_var is undefined, rather than failing the whole task. A small habit worth adopting anywhere a variable might legitimately be absent for some hosts but not others.
Fact gathering has a real cost on large inventories
Gathering facts means an extra SSH round trip and a real amount of data collected per host, every single play, by default — on a large inventory, that adds up. gather_facts: false set on a specific play is a genuine, worthwhile optimization when that play's own tasks don't actually need any fact data, not a setting to leave on everywhere purely out of habit.

Hands-On Exercises

Exercise 1

A variable app_port is set to 8080 in group_vars/webservers.yml and to 9090 via -e app_port=9090 on the command line. State which value wins, and explain why using this chapter's own precedence order.

📄 View solution
Exercise 2

Write a task that installs the package "firewalld" only when ansible_os_family is "RedHat", using the when: keyword and a gathered fact.

📄 View solution
Exercise 3

Write a small Jinja2 template snippet (as used inside a .j2 file) that renders a line "MaxConnections {{ max_connections }}" only if ssl_enabled is true, and explain why this has to be a {% if %} block rather than the task-level when: keyword.

📄 View solution

Chapter 5 Quick Reference

  • Practical variable precedence, low to high: role defaults → inventory vars → playbook vars → task vars/set_fact → -e (always wins)
  • Facts — auto-gathered host data (ansible_os_family, ansible_distribution, ...), usable in when: conditions
  • Jinja2 is the templating language behind every {{ }} — including {{ item }} and the default filter
  • copy transfers a file as-is; template renders Jinja2 first, then transfers the result
  • {% if %}/{% for %} are Jinja2 template-level constructs, distinct from the task-level when:/loop: keywords
  • template is the exact mechanism the capstone (ansible1-10) uses to generate ws1's own Apache/security-header config
  • gather_facts: false is a real, worthwhile optimization when a play's tasks don't need fact data