Variables, Facts and Templates (Jinja2)

K
Kai··5 min read

The playbook in Article 4 hard-codes every value. This article makes them flexible with three tightly-coupled things: variables (custom values), facts (host info Ansible already knows), and templates (dynamic files generated from variables + facts). This is where a playbook goes from "it runs" to "it's reusable across many environments".

Variables: where to declare them

Variables let the same playbook run with different values (dev vs prod, different ports...). There are several places to declare them:

# 1. Right in the play
- hosts: web
  vars:
    site_name: "KKloud Lab"
    http_port: 8080

# 2. From a separate file
- hosts: web
  vars_files:
    - vars/main.yml

On top of that (recall Article 3): group_vars/<group>.yml and host_vars/<host>.yml. And passing at run time with -e (extra-vars):

ansible-playbook site.yml -e "http_port=9090 app_env=staging"

Use a variable with Jinja2 syntax {{ variable_name }}:

- name: Open port
  ansible.builtin.debug:
    msg: "App running in environment {{ app_env }}, port {{ http_port }}"

Precedence — the part that trips people up

When the same variable is declared in multiple places, which one wins? Ansible has a clear precedence order. Simplified from low → high:

   LOW (easily overridden) ─────────────────────────────► HIGH (wins)

   role defaults  <  group_vars/all  <  group_vars/<group>  <  host_vars
       <  play vars  <  task vars  <  ...  <  -e extra-vars (ALWAYS wins)

Two things to remember:

  • -e (extra-vars) always beats everything — handy for overriding at run time ("run with port 9090 just this once"), but don't abuse it since it overrides everything.
  • role defaults are lowest — put a role's default values in defaults/ so they're easily overridden by group_vars/host_vars (Article 8).

Practical rule: put shared values in a low place (defaults, group_vars/all), put specific values in a higher place (host_vars, or -e when needed). (The Ansible docs have a full precedence table of ~22 levels; you don't need to memorize all of it, just grasp "more specific / later wins, -e beats all".)

Facts: host info Ansible already knows

Recall Article 4 — the Gathering Facts task at the start of every play. It collects facts: hundreds of pieces of information about the host (OS, IP, RAM, CPU, disk, hostname...), stored in ansible_* variables. See them all:

ansible all -m setup

A few commonly-used facts:

   ansible_hostname               host name
   ansible_distribution           "Amazon", "Ubuntu", "CentOS"...
   ansible_distribution_version   "2023", "24.04"...
   ansible_default_ipv4.address   primary IP
   ansible_processor_vcpus        number of vCPUs
   ansible_memtotal_mb            total RAM (MB)

Facts let a playbook adapt to the host: install different packages based on ansible_distribution, configure based on CPU count... (combined with when: in Article 7). If you don't need facts, set gather_facts: false to run faster.

Jinja2 templates: generate files dynamically

The copy module places a static file. The template module is more powerful: it renders a Jinja2 file (.j2) — substituting variables and facts — before placing it on the host. This is how you generate dynamic config files (nginx.conf, .env, app config...).

Create the template templates/index.html.j2:

<h1>{{ site_name }}</h1>
<p>Host: {{ ansible_hostname }} ({{ ansible_distribution }} {{ ansible_distribution_version }})</p>
<p>Môi trường: {{ app_env | default('dev') }}</p>

A playbook that uses it:

- name: Demo variables, facts, template
  hosts: web
  become: true
  vars:
    site_name: "KKloud Lab"
    app_env: "production"
  tasks:
    - name: Render template to index.html
      ansible.builtin.template:
        src: templates/index.html.j2
        dest: /usr/share/nginx/html/index.html

Run it, then curl to see the actual rendered result on the host:

<h1>KKloud Lab</h1>
<p>Host: ip-172-31-31-60 (Amazon 2023)</p>
<p>Môi trường: production</p>

Notice: {{ site_name }} and {{ app_env }} come from variables; {{ ansible_hostname }}, {{ ansible_distribution }} come from facts — Ansible knows the host's name and OS without you typing them. One template, applied to a hundred hosts, produces a file with each host's correct info.

Jinja2: filters, conditionals, loops inside templates

Jinja2 (also used in a playbook's {{ }}) has plenty of tools:

{{ app_env | default('dev') }}        {# filter: default value if unset #}
{{ name | upper }}                     {# filter: uppercase #}
{{ items | length }}                   {# filter: count elements #}

{% if app_env == 'production' %}        {# conditional #}
log_level = warning
{% else %}
log_level = debug
{% endif %}

{% for host in groups['web'] %}         {# loop — list the hosts in the web group #}
server {{ host }};
{% endfor %}

The {% for %} loop in templates is especially powerful: e.g. generate a load balancer config listing every host in the web group (via the groups variable). Filters (| default, | upper...) transform values — and you can write your own filters (Article 12).

registered variables: capture a task's result

You can also save a task's result into a variable with register, for a later task to use:

- name: Check nginx version
  ansible.builtin.command: nginx -v
  register: nginx_out
  changed_when: false        # read-only command, don't count it as changed

- name: Print it
  ansible.builtin.debug:
    var: nginx_out.stdout

register captures the JSON the module returns (including stdout, rc, changed...) — useful for deciding a later task based on a result (combined with when: — Article 7).

🧹 Cleanup

Sample code (template, vars) is in the nghiadaulau/ansible-series repo, directory 06-vars-facts-template. The EC2 lab stays up through Article 16.

Wrap-up

Variables make playbooks reusable, declared in many places with a precedence order (more specific/later wins; -e beats all). Facts are host info Ansible gathers automatically (ansible_*) so a playbook can adapt per machine. Jinja2 templates (the template module) render dynamic files from variables + facts (with filters, if, for) — producing the right config for each host. register captures a task's result for further use.

With variables and facts in hand, we need to control flow: when a task runs, how it repeats, how it reacts to changes. Article 7: handlers, loops and conditionals.