<aside> 🧭
Module 04 · Jinja2 Templating
Generating configuration files that are correct, readable and idempotent. This is where Ansible stops copying static files and starts producing config shaped by the host it is running against.
🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)
Prerequisite: Modules 01–03. You have used filters throughout — default, bool, dict2items, version. This module covers the engine underneath them.
</aside>
<aside> 📖
Official docs: Templating (Jinja2) · ansible.builtin.template module · Jinja2 template designer documentation
</aside>
<aside> 💌
The analogy. Think of a wedding invitation. The card is printed once and reads "Dear __, we would be delighted…". You write a different name in the blank for each guest, and every other word on the card stays exactly the same. One card design, three hundred personalised results.
A Jinja2 template is that card, and {{ guest_name }} is the blank. The three kinds of marking on it do three different jobs: {{ }} fills in a blank, {% %} is an instruction to whoever is printing — "repeat this line once per guest" — and {# #} is a pencil note to yourself that never appears on the finished card.
</aside>
Jinja2 has exactly three delimiter types, and confusing them is the source of most template errors.
| Syntax | Name | Does what |
|---|---|---|
{{ ... }} |
Expression | Evaluates something and prints the result into the output |
{% ... %} |
Statement | Control flow — for, if, set, macro. Prints nothing itself |
{# ... #} |
Comment | Removed entirely — never appears in the rendered file |
{# This comment will not appear in the output file #}
{% set worker_count = ansible_facts['processor_vcpus'] * 2 %}
worker_processes {{ worker_count }};
{% for host in groups['web'] %}
server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:8080;
{% endfor %}
<aside> 🔑
The distinction that trips people up: {% if x %} decides whether something is emitted; {{ x }} emits a value. Writing {{ if x }} or {% my_var %} are both errors, and the error messages are not always obvious about which mistake you made.
</aside>
<aside> ✉️
The analogy. Think about where you actually write the guest's name on that invitation. You fill it in at your own kitchen table and then post the finished card. You do not post a blank card and a pen and ask the guest to fill in their own name — that would be absurd, and they would not know what to write.
Templates are rendered on the control node, and only the finished text is sent. The guest receives a completed card and has no idea a template was ever involved — which is why the target server needs no templating software installed, and why lookup('file', …) inside a template reads your filing cabinet rather than the server's. People lose hours to that last point.
</aside>
flowchart TD
A["nginx.conf.j2<br>lives on the CONTROL NODE"] --> B["Jinja2 renders it<br>ON THE CONTROL NODE<br>using this host's variables and facts"]
B --> C["Result is a plain text file<br>no Jinja2 left in it"]
C --> D["Copied to the managed node<br>via the normal module transport"]
D --> E{"validate: supplied?"}
E -->|"Yes"| F["Run the validator<br>against the TEMP file"]
F -->|"fails"| G["Task fails<br>live file untouched"]
F -->|"passes"| H["Move into place"]
E -->|"No"| H
H --> I{"Content differs<br>from what was there?"}
I -->|"Yes"| J["changed: true<br>handlers notified"]
I -->|"No"| K["ok: unchanged<br>handlers NOT notified"]
style B fill:#DDD6FE,stroke:#7C3AED,stroke-width:2px
style G fill:#FEE2E2,stroke:#DC2626
style K fill:#D1FAE5,stroke:#059669
<aside> 🔑
Templates render on the control node, never on the target. The managed node receives finished text and has no idea Jinja2 was involved. This is why the target needs no Jinja2, no Python templating library, nothing.
It is also why lookup('file', ...) inside a template reads a control node file — the same rule from Module 02 Part D4.
</aside>
template moduleAnsible templates almost every string in a playbook before using it:
- name: "Deploy {{ app_name }}" # task names are templated
ansible.builtin.copy:
dest: "/opt/{{ app_name }}/config" # arguments are templated
content: "port={{ app_port }}"
when: app_port | int > 1024 # conditions are Jinja2 expressions
loop: "{{ app_list }}" # loop sources are templated
vars:
computed: "{{ base_dir }}/{{ app_name }}" # variables can reference variables
<aside> ⚠️
The one place templating does NOT happen: inside a file copied with copy. copy transfers bytes verbatim. If your file contains {{ something }}, it arrives with the braces intact.
That is occasionally exactly what you want — deploying a file that itself contains Jinja2 syntax, such as a Grafana dashboard or a Prometheus rule using its own templating.
</aside>