Ansible Deep Dive Part 4: Variables, Facts, and Jinja2 Templating
Ansible’s templating language is Jinja2 — the same engine Flask uses for HTML, repurposed here for generating config files, evaluating conditionals, and transforming data inline in YAML. If you’ve used my Fortinet SD-WAN Jinja Orchestrator series, this will look familiar; Ansible just wraps the same engine in its own variable system.
Facts: what Ansible already knows about a host
Every play starts with a gather_facts step (on by default) that runs the setup module and populates ansible_facts with everything about the target — OS family, IP addresses, memory, mounted filesystems, CPU count, and hundreds more keys.
ansible web1.lab.local -m setup | less
Worth running once against an unfamiliar host just to see what’s available. Common ones in daily use:
ansible_facts['os_family'] # 'Debian', 'RedHat', ...
ansible_facts['distribution'] # 'Ubuntu', 'CentOS', ...
ansible_facts['default_ipv4']['address']
ansible_facts['memtotal_mb']
ansible_facts['hostname']
Fact gathering has a real time cost across a large fleet — it’s an extra round trip to every host before any actual work starts. If a playbook genuinely doesn’t reference any fact, gather_facts: false on the play skips it and speeds up large runs meaningfully. I leave it on by default and only disable it deliberately, because a when: ansible_facts[...] condition added six months later by someone who’s forgotten it’s off is a very quiet way to break a playbook.
Variable precedence, briefly (the full ladder is Part 6)
Variables can come from the inventory (group_vars/host_vars), the playbook (vars:), a role’s defaults/main.yml, the command line (-e), facts, and registered task output — and when the same variable name is set in more than one place, Ansible has a defined order of precedence that resolves the conflict. I’m deferring the full eleven-level list to Part 6 because it’s tangled up with Vault and worth covering as one topic, but the one rule to internalize now: -e on the command line always wins, which is exactly why it’s the right tool for “override this one thing for this one run” without touching any files.
Jinja2 syntax cheat sheet
{{ variable }} {# print a value #}
{{ variable | default('none') }} {# filter: fallback if undefined #}
{% if condition %} ... {% endif %} {# conditional block #}
{% for item in list %} ... {% endfor %} {# loop block #}
{{ }} for expressions, {% %} for control flow, | to pipe a value through a filter. Ansible extends stock Jinja2 with its own filter library on top of the standard set.
Filters that earn their keep
vars:
hostnames: "{{ groups['webservers'] | map('extract', hostvars, 'ansible_host') | list }}"
is_prod: "{{ 'prod' in inventory_hostname }}"
port: "{{ http_port | default(80) | int }}"
safe_name: "{{ site_name | replace(' ', '-') | lower }}"
short: "{{ long_description | truncate(80) }}"
first_ip: "{{ ansible_facts['all_ipv4_addresses'] | first }}"
default() is the one I use most — every variable that might not be set should have a sensible fallback rather than causing an “undefined variable” failure mid-run. map/select/selectattr/rejectattr let you transform and filter lists without dropping into a full loop — genuinely useful once you’re pulling values out of hostvars across the whole inventory rather than just the current host.
The template module: where this all comes together
{# templates/site.conf.j2 #}
server {
listen {{ http_port }};
server_name {{ inventory_hostname }};
{% if enable_ssl | default(false) %}
listen 443 ssl;
ssl_certificate {{ ssl_cert_path }};
{% endif %}
location / {
proxy_pass http://{{ upstream_pool | join(',') }};
}
}
- name: Deploy site configuration
ansible.builtin.template:
src: templates/site.conf.j2
dest: /etc/nginx/sites-available/default
owner: root
group: root
mode: '0644'
notify: Reload nginx
Every variable in scope for the host — inventory vars, facts, role defaults, everything — is available inside the template with no extra wiring. This is the exact mechanism I’d use to generate a FortiGate or Cisco config block from a single template rendered once per device in inventory, substituting real IPs, hostnames, and per-site values pulled straight from host_vars/.
register and set_fact: turning task output back into a variable
- name: Check if maintenance file exists
ansible.builtin.stat:
path: /opt/app/maintenance.flag
register: maintenance_check
- name: Show a warning if in maintenance mode
ansible.builtin.debug:
msg: "Site is in maintenance mode"
when: maintenance_check.stat.exists
- name: Compute a derived value once
ansible.builtin.set_fact:
backup_name: "{{ inventory_hostname }}-{{ ansible_date_time.date }}.tar.gz"
register captures a task’s full result — return code, stdout, changed status, everything — into a variable you can inspect in later tasks. set_fact is for values you compute yourself rather than capture from a module. Both are used constantly in the labs coming up in Parts 9 and 10, where later tasks need to make decisions based on what an earlier task discovered.
Part 5 moves up a level of organization: once a playbook has a template, a handler, some variables, and a handful of tasks that clearly belong together as “how we configure a web server,” that’s exactly the shape a role is designed to hold — and roles are how you stop copy-pasting the same fifteen lines into every new project.