Variables, Facts and Templates (Jinja2)
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.