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.