Module 04 — Jinja2 Templating
Updated 20 August 2026
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.
Part A · How templating works
A1 · The three delimiters
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.
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 %}A2 · Where templating happens — and where it does not
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.
Diagram source
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:#059669It is also why lookup('file', ...) inside a template reads a control node file — the same rule from Module 02 Part D4.
Templating is not limited to the template module
Ansible 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 variablesThat 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.
A3 · template vs copy vs lineinfile vs blockinfile
The card with blanks is template, the photocopy is copy, and the crossing-out is lineinfile (with blockinfile for crossing out a whole paragraph at once). The warning is the one your eyes give you: fifteen crossings-out on a single page means you should have typed a fresh document. That is precisely the moment a pile of lineinfile tasks should have become one template.
| Module | Use when | Do not use when |
|---|---|---|
| template | You own the whole file and it varies by host | The content is identical everywhere — copy is cheaper and clearer |
| copy | You own the whole file and it is static | Anything needs substituting |
| lineinfile | You need one line in a file owned by the OS or a package | You are managing more than two or three lines — it gets unreadable fast |
| blockinfile | You need a managed section inside someone else's file | You own the whole file — use template |
People who reach for lineinfile fifteen times on one file should have written a template. It is slower, harder to review, and each lineinfile is an independent regex that can drift.
🧪 Exercise A3.1 — Prove that templates render on the control node
mkdir -p templates
cat > templates/proof.j2 <<'EOF'
rendered_on_control_node = {{ lookup('pipe', 'hostname') }}
rendered_for_target = {{ inventory_hostname }}
target_reported_hostname = {{ ansible_facts['hostname'] }}
control_node_user = {{ lookup('env', 'USER') }}
EOF---
- name: Where does templating happen?
hosts: web01
gather_facts: true
tasks:
- ansible.builtin.template:
src: proof.j2
dest: /tmp/proof.txt
mode: "0644"
- ansible.builtin.command: cat /tmp/proof.txt
register: out
changed_when: false
- ansible.builtin.debug:
var: out.stdout_lines✅ Expected result — click to reveal
ok: [web01] => {
"out.stdout_lines": [
"rendered_on_control_node = zaeem-laptop",
"rendered_for_target = web01",
"target_reported_hostname = web01",
"control_node_user = zaeem"
]
}Line 1 is the proof. lookup('pipe', 'hostname') ran hostname and got your laptop, not web01 — because lookups and template rendering both happen on the control node.
Line 3 shows the target's hostname, but only because it arrived as a gathered fact — collected earlier by the setup module and then substituted locally during rendering. The template itself never executed anything on web01.
Why this matters practically: if you need a value that only exists on the target and is not a fact, you cannot get it with a lookup. You must run a task with register first, then reference the registered value in the template. Reaching for lookup('file', '/etc/something') and getting the control node's copy is a genuinely common bug.
🎯 Interview questions — Templating basics
Q. What is the difference between {{ }}, {% %} and {# #}?
{{ }} is an expression — it evaluates and prints the result into the output.
{% %} is a statement — control flow such as for, if, set, macro. It prints nothing itself.
{# #} is a comment — stripped entirely, never present in the rendered file. Useful for notes you do not want shipped to production.
Q. Where does template rendering happen — control node or target?
The control node, always. Jinja2 renders the file locally using that host's variables and facts, then the finished plain text is transferred to the target.
That is why the managed node needs no templating library, and why lookup() inside a template reads control-node files rather than target files. To use a value that exists only on the target, run a task and register it first.
Q. When would you use template over copy, and lineinfile over both?
template when you own the entire file and its content varies by host. copy when you own it and it is identical everywhere — cheaper and clearer.
lineinfile or blockinfile when the file is owned by the OS or a package and you only need to manage a line or a section within it.
The warning worth adding: fifteen lineinfile tasks against one file should have been a template. Each is an independent regex that can drift, and the result is far harder to review than a single declared file.
Q. Is anything besides the template module templated?
Almost everything. Task names, module arguments, when: conditions, loop: sources, variable definitions that reference other variables — all rendered through Jinja2 before use.
The notable exception is a file transferred with copy, which is sent byte-for-byte. That is deliberate, and it is how you deploy a file that legitimately contains Jinja2 syntax of its own — a Grafana dashboard or a Prometheus rule, for example.
Part B · Filters
Filters are those stations, and the | is the conveyor belt. {{ name | trim | lower | replace(' ', '-') }} washes, peels and chops, strictly left to right. And exactly as in a real kitchen, the order is not a detail: frying before peeling gives you something quite different, and most "my filter is not working" problems are really a station in the wrong place.
A filter transforms a value. Syntax is value | filter and they chain left to right.
{{ " Hello World " | trim | lower | replace(' ', '-') }} -> hello-worldB1 · String filters
"{{ name | upper }}" # ZAEEM
"{{ name | lower }}" # zaeem
"{{ name | capitalize }}" # Zaeem
"{{ text | trim }}" # strip leading/trailing whitespace
"{{ text | replace('old', 'new') }}"
"{{ path | basename }}" # /opt/app/x.conf -> x.conf
"{{ path | dirname }}" # /opt/app/x.conf -> /opt/app
"{{ file | splitext | first }}" # x.conf -> x
"{{ csv | split(',') }}" # string -> list
"{{ list | join(', ') }}" # list -> string
"{{ text | regex_replace('^v', '') }}" # v2.4.1 -> 2.4.1
"{{ text | regex_search('[0-9]+\\.[0-9]+') }}"
"{{ log | regex_findall('ERROR: (.*)') }}" # every match, as a list
"{{ cmd | quote }}" # shell-escape - IMPORTANT for safety
"{{ text | comment }}" # wrap in # comment markers- ansible.builtin.shell: "grep {{ user_pattern | quote }} /var/log/app.log"Better still, avoid shell entirely. But when you cannot, | quote is the answer, and knowing it exists is exactly the kind of detail that reads as security-aware in an interview.
B2 · List and dictionary filters
"{{ list | length }}" # count
"{{ list | first }}" / "{{ list | last }}"
"{{ list | unique }}" # de-duplicate
"{{ list | sort }}" / "{{ list | sort(reverse=true) }}"
"{{ list | flatten }}" # nested list -> flat
"{{ list_a | union(list_b) }}" # A or B
"{{ list_a | intersect(list_b) }}" # A and B
"{{ list_a | difference(list_b) }}" # in A, not in B
"{{ list | random }}" / "{{ list | shuffle }}"
"{{ dict | dict2items }}" # {a: 1} -> [{key: a, value: 1}]
"{{ items | items2dict }}" # the reverse
"{{ dict_a | combine(dict_b, recursive=True) }}" # merge - Module 02map, select, reject — the ones that separate people
# Extract one attribute from a list of dictionaries
"{{ users | map(attribute='name') | list }}"
# [{name: a, uid: 1}, {name: b, uid: 2}] -> ['a', 'b']
# Filter a list of dictionaries by an attribute
"{{ users | selectattr('uid', '>=', 2000) | list }}"
"{{ users | selectattr('shell', 'defined') | list }}"
"{{ users | rejectattr('name', 'equalto', 'root') | list }}"
# Filter a plain list
"{{ ports | select('>', 1024) | list }}"
"{{ names | select('match', '^web') | list }}"
# Chain them - extract, filter, sort, join
"{{ users | selectattr('active') | map(attribute='name') | sort | join(', ') }}"B3 · Type, math and default filters
"{{ value | int }}" / "{{ value | float }}" / "{{ value | bool }}"
"{{ value | string }}"
"{{ value | type_debug }}" # what type IS this? - Module 02
"{{ myvar | default('fallback') }}"
"{{ myvar | default('fallback', true) }}" # also replaces empty/falsy
"{{ myvar | default(omit) }}" # remove the argument entirely
"{{ myvar | mandatory }}" # fail loudly if undefined
"{{ condition | ternary('yes', 'no') }}" # inline if/else
"{{ nums | max }}" / "{{ nums | min }}" / "{{ nums | sum }}"
"{{ number | round(2) }}"
"{{ bytes | human_readable }}" # 1048576 -> 1.00 MB
"{{ '1.5GB' | human_to_bytes }}" # -> 1610612736worker_processes {{ (ansible_facts['processor_vcpus'] > 4) | ternary('auto', 2) }};It also takes a third argument for the undefined case: ternary('yes', 'no', 'unknown').
B4 · Data format filters
"{{ data | to_json }}" # compact JSON
"{{ data | to_nice_json(indent=2) }}" # pretty JSON
"{{ data | to_yaml }}"
"{{ data | to_nice_yaml(indent=2) }}" # readable YAML - great in templates
"{{ json_string | from_json }}" # parse JSON into a real object
"{{ yaml_string | from_yaml }}"
"{{ text | b64encode }}" / "{{ text | b64decode }}"
"{{ 'string' | hash('sha256') }}"
"{{ 'string' | password_hash('sha512') }}" # for the user module's password field# WRONG - changed on every run, forever
password: "{{ raw_pw | password_hash('sha512') }}"
# RIGHT - deterministic salt derived from something stable
password: "{{ raw_pw | password_hash('sha512', 65534 | random(seed=inventory_hostname) | string) }}"This is a genuinely common production bug and a favourite interview follow-up on idempotency, because it looks correct and behaves badly.
B5 · Network and time filters
"{{ '192.168.1.5/24' | ansible.utils.ipaddr('network') }}" # 192.168.1.0
"{{ '192.168.1.0/24' | ansible.utils.ipaddr('netmask') }}" # 255.255.255.0
"{{ addr | ansible.utils.ipaddr('address') }}"
"{{ 'https://a.com:8080/path' | urlsplit('hostname') }}" # a.com
"{{ '%Y-%m-%d' | strftime }}" # today's date
"{{ ansible_date_time.epoch | int | strftime('%Y-%m-%d %H:%M') }}"🧪 Exercise B5.1 — Build a real value with a filter chain
---
- name: Filter chains
hosts: localhost
gather_facts: false
vars:
app_users:
- { name: "root", uid: 0, active: true }
- { name: "deploy", uid: 2001, active: true }
- { name: "monitor", uid: 2002, active: true }
- { name: "olduser", uid: 2003, active: false }
tasks:
- name: Active non-root users, sorted, comma-separated
ansible.builtin.debug:
msg: >-
{{ app_users
| selectattr('active')
| rejectattr('name', 'equalto', 'root')
| map(attribute='name')
| sort
| join(', ') }}
- name: What happens WITHOUT the final list
ansible.builtin.debug:
msg: "{{ app_users | map(attribute='name') }}"
- name: With the final list
ansible.builtin.debug:
msg: "{{ app_users | map(attribute='name') | list }}"✅ Expected result — click to reveal
TASK [Active non-root users, sorted, comma-separated] **************
ok: [localhost] => {
"msg": "deploy, monitor"
}
TASK [What happens WITHOUT the final list] *************************
ok: [localhost] => {
"msg": "<generator object do_map at 0x7f2a1c4d5e40>"
}
TASK [With the final list] *****************************************
ok: [localhost] => {
"msg": ["root", "deploy", "monitor", "olduser"]
}The middle task is the lesson. map returned a lazy generator, and rendering it produced a Python repr string. No error, no warning — and if that had gone into a config file you would have deployed <generator object do_map at 0x7f2a1c4d5e40> to production.
The rule: map, select, reject, selectattr and rejectattr all need a trailing | list unless another filter that consumes them follows (join, sort, length all force evaluation, which is why the first task worked without one).
Also note the >- folded scalar used to split the chain across lines. A six-filter chain on one line is unreadable; this keeps it reviewable in a diff without changing the value.
🎯 Interview questions — Filters
Q. How do you extract one field from a list of dictionaries?
{{ users | map(attribute='name') | list }}.
To filter first, selectattr and rejectattr: {{ users | selectattr('active') | map(attribute='name') | list }}.
The detail worth volunteering: map and select return generators, so a trailing | list is needed unless a consuming filter such as join or sort follows. Without it you render a <generator object ...> string into your file with no error.
Q. Why is password_hash a classic idempotency bug?
Without a fixed salt it generates a new random salt on every run, producing a different hash each time. The user module then sees a different value, reports changed, and resets the password on every single run — which also fires any handler attached to it.
The fix is a deterministic salt, typically derived from something stable like inventory_hostname via random(seed=...). Better still, store an already-hashed value in Vault.
Q. How do you safely interpolate a variable into a shell command?
| quote — it shell-escapes the value, so a string containing ; or backticks cannot break out and run a second command.
The stronger answer is to not use shell at all where a real module exists. When you must, | quote on every user-controlled value is mandatory, and command is preferable to shell because it does not involve a shell at all.
Q. What is ternary for?
An inline conditional inside an expression: {{ (vcpus > 4) | ternary('auto', 2) }}. It keeps a template readable when a full {% if %} block would be heavy for a single value.
It takes an optional third argument for the undefined case.
Q. How do you merge two dictionaries in a template?
{{ base | combine(overrides, recursive=True) }}.
recursive=True matters whenever there are nested dictionaries — without it the nested level is replaced wholesale rather than merged, which silently drops keys. Covered in Module 02 Part D2.
Part C · Control structures in templates
C1 · Loops
loop.last is knowing you are on the final name, loop.index is the number in the margin, and {% else %} inside a for loop is writing "no guests". That trailing comma sounds trivial until it is the difference between valid and invalid JSON in a generated config file.
{% for host in groups['web'] %}
server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:8080;
{% endfor %}The loop object — available inside any for
| Variable | Value |
|---|---|
| loop.index | 1-based counter |
| loop.index0 | 0-based counter |
| loop.first / loop.last | booleans — very useful for separators |
| loop.length | total number of items |
| loop.revindex | counts down to 1 |
{# Comma-separated list with no trailing comma #}
servers = [{% for h in groups['web'] %}"{{ h }}"{% if not loop.last %}, {% endif %}{% endfor %}]
{# Or simply, which is clearer: #}
servers = {{ groups['web'] | map('quote') | join(', ') }}{% for h in groups['web'] %}
server {{ h }};
{% else %}
# no web servers defined
{% endfor %}That is a genuinely useful pattern — an empty loop silently producing an empty config block is how you end up with an nginx upstream that has no members and refuses to start.
C2 · Conditionals and set
{% if ansible_facts['os_family'] == 'Debian' %}
include /etc/nginx/sites-enabled/*;
{% elif ansible_facts['os_family'] == 'RedHat' %}
include /etc/nginx/conf.d/*.conf;
{% else %}
# unsupported platform: {{ ansible_facts['os_family'] }}
{% endif %}
{% set worker_count = (ansible_facts['processor_vcpus'] * 2) | int %}
{% set is_prod = 'prod' in group_names %}
worker_processes {{ worker_count }};
{% if is_prod %}error_log /var/log/nginx/error.log warn;{% endif %}Use a namespace object instead:
{% set ns = namespace(total=0) %}
{% for item in items %}{% set ns.total = ns.total + item.size %}{% endfor %}
Total: {{ ns.total }}Or, far better in Ansible, do it with a filter: {{ items | map(attribute='size') | sum }}.
C3 · Whitespace control — the thing that makes templates look professional
The {% if %} and {% for %} lines in a template are those guidelines: they exist to organise you, not to appear in the output. Without whitespace control they leave their marks behind as stray blank lines and doubled-up indentation in your finished config file. trim_blocks and lstrip_blocks are the eraser — and in a YAML or Python config, where indentation carries meaning, those leftover pencil marks are not cosmetic.
Jinja2 statements sit on their own lines, and by default those lines leave blank space behind.
{% for h in hosts %}
server {{ h }};
{% endfor %}| Control | Ansible default | Effect |
|---|---|---|
| trim_blocks | true | Removes the first newline after a block tag |
| lstrip_blocks | false | Strips whitespace from line start up to a block tag |
| {%- ... %} | — | Strip whitespace before this tag |
| {% ... -%} | — | Strip whitespace after this tag |
The one you usually still want is lstrip_blocks: true, which lets you indent your {% if %} and {% for %} tags to match the structure of the file without those indents appearing in the output.
🧪 Exercise C3.1 — Fix an ugly generated file
{# templates/upstream.j2 #}
upstream backend {
{% for host in groups['web'] %}
server {{ host }}:8080;
{% endfor %}
}- ansible.builtin.template:
src: upstream.j2
dest: /tmp/upstream.conf
mode: "0644"
# then add lstrip_blocks and compare:
# lstrip_blocks: true✅ Expected result — click to reveal
Default — trim_blocks: true, lstrip_blocks: false:
upstream backend {
server web01:8080;
server web02:8080;
server web03:8080;
}Look closely: eight spaces of indent instead of four. The four spaces you used to indent {% for %} were emitted into the output and added to the four in front of server.
With lstrip_blocks: true:
upstream backend {
server web01:8080;
server web02:8080;
server web03:8080;
}And if trim_blocks were false — as it is in plain Jinja2 — you would additionally get a blank line after every {% endfor %} and {% if %}, producing a config file with gaps scattered through it.
Why this is worth caring about: a generated config with erratic indentation and stray blank lines is harder for humans to review during an incident, and in whitespace-sensitive formats — YAML, Python, Makefiles — it is not cosmetic at all, it is broken output. Set lstrip_blocks: true on templates where you indent your tags.
C4 · Macros and template reuse
{# macros.j2 #}
{% macro server_block(name, port, ssl=false) -%}
server {
listen {{ port }}{% if ssl %} ssl{% endif %};
server_name {{ name }};
}
{%- endmacro %}{# site.conf.j2 #}
{% import 'macros.j2' as m %}
{{ m.server_block('example.com', 80) }}
{{ m.server_block('secure.example.com', 443, ssl=true) }}| Directive | Behaviour |
|---|---|
| {% include 'f.j2' %} | Renders another template inline, with access to the current context |
| {% import 'f.j2' as m %} | Imports macros. Does not get the current context by default |
| {% import ... with context %} | Imports macros and passes the current variables |
🎯 Interview questions — Template control structures
Q. What is trim_blocks and what is Ansible's default?
It removes the first newline after a block tag, so {% for %} and {% endfor %} on their own lines do not each leave a blank line behind.
Ansible sets it to true by default, which differs from plain Jinja2 where it is false. lstrip_blocks defaults to false in both, and is the one you usually want to enable so you can indent your tags to match the file structure without those indents appearing in the output.
Q. Why does accumulating a total inside a {% for %} loop not work?
Jinja2 scoping — a variable {% set %} inside a loop body is discarded at the end of each iteration, so the total resets. It produces a wrong answer silently rather than an error.
Fix with a namespace() object whose attributes persist across iterations. In Ansible the better fix is usually to avoid the loop entirely: {{ items | map(attribute='size') | sum }}.
Q. How do you avoid a trailing comma when generating a list?
{% if not loop.last %}, {% endif %} inside the loop, using the loop.last boolean.
The loop object also gives you index, index0, first, length and revindex.
Often cleaner in Ansible, though: skip the loop and use {{ mylist | join(', ') }}.
Q. Difference between include and import in a template?
{% include %} renders another template inline and inherits the current context, so it sees your variables.
{% import %} brings in macros and does not get the current context unless you add with context. A macro referencing ansible_facts under a plain import fails with an undefined-variable error.
The cleaner design is to pass what a macro needs as explicit arguments rather than relying on ambient context — it is easier to test and easier to read.
Part D · Templating in production
D1 · The template module's options that matter
validate: is that proofread. Ansible renders the config to a temporary file, runs the real checker against it — nginx -t, visudo -c, sshd -t — and only moves it into place if it passes. If it fails, the live file is never touched at all. That matters most for exactly the three files that lock you out of the building when you get them wrong: sshd, sudoers and nginx.
- name: Deploy nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s # test BEFORE putting it in place
backup: true # keep a timestamped copy of the old file
lstrip_blocks: true # strip indent before block tags
trim_blocks: true # default, shown for clarity
block_start_string: "[%" # change delimiters when they clash
variable_start_string: "[["
notify: Reload nginx| Option | Why you want it |
|---|---|
| validate: | %s is replaced with the temp path. Fails the task and leaves the live file untouched if the config is invalid |
| backup: true | Keeps a timestamped copy on the target. Cheap insurance during a risky change |
| lstrip_blocks: true | Lets you indent tags without the indent reaching the output |
| variable_start_string | Escape hatch when the target format uses {{ }} itself — Helm charts, Grafana dashboards, Prometheus rules |
| force: false | Write only if the file does not already exist — for seeding a file a human will then edit |
# {{ ansible_managed }}
# DO NOT EDIT - changes will be overwritten on the next Ansible runIt renders to something like Ansible managed, and the string is configurable in ansible.cfg. It is the difference between a colleague at 3am editing the file by hand and losing their work, and knowing immediately where the file comes from.
One caution worth knowing: if you configure it to include a timestamp or the source path, the file content changes on every run and the task reports changed forever — the same idempotency trap as password_hash. Keep it static.
D2 · Patterns you will actually write
An upstream block from group membership
# {{ ansible_managed }}
upstream {{ app_name }}_backend {
{% for host in groups['web'] %}
{% if hostvars[host]['ansible_default_ipv4'] is defined %}
server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:{{ app_port }} max_fails=3;
{% else %}
# {{ host }}: facts not gathered - run a play against 'web' first
{% endif %}
{% endfor %}
}A config file from a dictionary
# {{ ansible_managed }}
{% for key, value in app_settings | dictsort %}
{{ key }} = {{ value }}
{% endfor %}Sorting anything you iterate is an idempotency measure, not a cosmetic one. Same reasoning applies to | sort on lists built from groups or from facts.
A systemd unit
# {{ ansible_managed }}
[Unit]
Description={{ app_name }}
After=network.target{% if app_needs_db %} postgresql.service{% endif %}
[Service]
User={{ app_user }}
Environment="PORT={{ app_port }}"
{% for k, v in (app_env | default({})) | dictsort %}
Environment="{{ k }}={{ v }}"
{% endfor %}
ExecStart={{ app_bin }} --config {{ app_config_path }}
Restart=always
[Install]
WantedBy=multi-user.targetD3 · The four gotchas
That is why a timestamp inside a generated file makes the task report changed on every run. The service then restarts nightly for no reason, and changed=0 — your best single signal that a system has settled — stops meaning anything at all. It is the six-eggs idea from Module 01 broken by one thoughtless line in a template.
You can change the behaviour with #jinja2: undefined: ChainableUndefined at the top of a template, but do not: silently rendering an empty string into a config file is far more dangerous than failing.
Ansible has a native types mode — jinja2_native = True in ansible.cfg — which preserves real Python types. It is genuinely useful and it changes behaviour subtly across a whole project, so it is a decision to take deliberately rather than mid-debug.
🧪 Exercise D3.1 — Hit all four gotchas deliberately
{# templates/gotchas.j2 #}
optional_value = {{ maybe_missing }}
math_result = {{ 5 + 5 }}
literal_braces = {% raw %}{{ this_stays_literal }}{% endraw %}---
- name: Template gotchas
hosts: localhost
gather_facts: false
tasks:
- name: This fails - undefined variable
ansible.builtin.template:
src: gotchas.j2
dest: /tmp/gotchas.txt
mode: "0644"
ignore_errors: true
- name: Check the type of a templated number
ansible.builtin.debug:
msg: "{{ (5 + 5) | type_debug }}"✅ Expected result — click to reveal
TASK [This fails - undefined variable] *****************************
fatal: [localhost]: FAILED! => {
"msg": "AnsibleUndefinedVariable: 'maybe_missing' is undefined"
}
...ignoring
TASK [Check the type of a templated number] ************************
ok: [localhost] => {
"msg": "str"
}Two lessons in four lines of template.
The undefined variable killed the entire file, not just that line — /tmp/gotchas.txt was never created. Add | default('unset') and it renders. This is why every optional value in a real template needs a default.
5 + 5 produced the string "10". Jinja2 arithmetic worked, then the result was stringified on the way out. So when: computed_value > 5 compares a string to an integer and behaves unpredictably — the same family of bug as the "false" string in Module 02 Part D5. Cast with | int, or enable jinja2_native.
Now fix the template and re-run to see {% raw %} working:
optional_value = unset
math_result = 10
literal_braces = {{ this_stays_literal }}{% raw %} passed the braces through untouched — which is how you template a file for a system that has its own {{ }} syntax.
D4 · Testing templates without deploying them
# Render to stdout without touching the target
ansible localhost -m ansible.builtin.debug \
-a "msg={{ lookup('template', 'templates/nginx.conf.j2') }}"
# Render for a REAL host, using its real facts, changing nothing
ansible-playbook site.yml --check --diff --limit web01 --tags config
# Render to a scratch path, inspect, then delete
ansible-playbook site.yml -e "nginx_conf_dest=/tmp/preview.conf" --limit web01For serious template work, Molecule (Module 11) renders against real containers and asserts on the result.
🎯 Interview questions — Production templating
Q. What does validate: do on the template module?
Runs a validation command against the temporary file before it is moved into place, substituting %s with the temp path. If validation fails, the task fails and the live file is never touched.
Use it wherever a broken config is costly: nginx -t -c %s, visudo -cf %s, sshd -t -f %s. A malformed sudoers file locks everyone out of sudo, including you.
Q. A template deploys a Helm chart that itself uses {{ }}. How?
Three options. Wrap the literal sections in {% raw %} ... {% endraw %}. Change the delimiters on the task with variable_start_string and block_start_string. Or — if nothing in the file actually needs substituting — use copy instead, which transfers bytes verbatim and never templates.
The same problem arises with Grafana dashboards, Prometheus rules and most front-end frameworks.
Q. Your template task reports changed on every run even though nothing changed. Why?
Something in the rendered content differs each time. The usual culprits: a timestamp — including a customised ansible_managed containing one; password_hash without a fixed salt; unsorted iteration over a dictionary or a set, where ordering is not stable; or a random filter without a seed.
The fix is to make the render deterministic — | dictsort or | sort on anything iterated, a fixed salt, and a static ansible_managed. Diagnose it with --diff, which shows exactly which line moved.
Q. What is jinja2_native and why would you enable it?
By default every Jinja2 expression renders to a string, so {{ 5 + 5 }} yields "10" and a list becomes its string representation. jinja2_native = True in ansible.cfg preserves real Python types — integers, lists, dictionaries, booleans.
It is genuinely useful when passing structured data between tasks, but it changes behaviour subtly across the entire project, so it is a deliberate project-wide decision rather than a quick fix. Existing playbooks relying on string coercion can break.
Q. How do you test a template without deploying it?
ansible-playbook --check --diff --limit web01 renders against the real host with its real facts and shows the exact diff, changing nothing — this is the everyday answer.
For quick syntax iteration, lookup('template', 'file.j2') in a debug task, with the caveat that it renders using localhost's facts.
For anything serious, Molecule renders against real containers and asserts on the output.
Part E · Putting it together
E1 · Production practice
| Habit | Why |
|---|---|
| {{ ansible_managed }} header on every generated file | Stops a colleague hand-editing it at 3am and losing the change on the next run |
| Keep ansible_managed static — no timestamps | A timestamp makes the file differ every run, so the task reports changed forever |
| validate: on sshd, sudoers, nginx and anything that can lock you out | Tests the temp file; the live file is never touched if it is invalid |
| | dictsort or | sort on anything you iterate | Unstable ordering makes the render differ between runs — a false changed and a needless restart |
| | default(...) on every optional variable | One undefined variable aborts the entire template, not just its line |
| Trailing | list after map, select, selectattr | Otherwise you render <generator object ...> into the file with no error |
| | quote on any value interpolated into a shell command | Shell injection — a value containing ; becomes a second command |
| lstrip_blocks: true when you indent your tags | Otherwise the tag indentation is emitted into the output |
| Prefer filters over loop-and-accumulate | {% set %} in a loop does not persist — silently wrong totals |
| --check --diff before deploying any template change | The rendered result is often not what you pictured |
E2 · Capstone exercise
Brief. Write a template that generates an nginx config for the web group, and a task to deploy it:
- Header marking it as Ansible-managed
- An upstream block listing every active host in the web group, by IP, sorted
- Hosts whose facts are missing must produce a comment, not a crash
- worker_processes set to twice the CPU count, but capped at 16
- TLS directives included only when enable_tls is true, defaulting to false if unset
- Correct indentation — no stray whitespace from the template tags
- Validated before going live, and must report changed=0 on a second run
✅ Model answer — attempt it first, then click
templates/nginx.conf.j2:
# {{ ansible_managed }}
# DO NOT EDIT - generated by Ansible
{# Requirement 4: twice the CPUs, capped at 16 #}
{% set workers = [(ansible_facts['processor_vcpus'] | int) * 2, 16] | min %}
worker_processes {{ workers }};
http {
upstream app_backend {
{% for host in groups['web'] | sort %}
{% if hostvars[host]['ansible_facts']['default_ipv4'] is defined %}
server {{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}:8080 max_fails=3;
{% else %}
# {{ host }}: facts unavailable - gather facts for the web group first
{% endif %}
{% endfor %}
}
server {
{% if enable_tls | default(false) | bool %}
listen 443 ssl;
ssl_certificate {{ tls_cert | default('/etc/ssl/certs/app.pem') }};
ssl_certificate_key {{ tls_key | default('/etc/ssl/private/app.key') }};
{% else %}
listen 80;
{% endif %}
server_name {{ server_name | default('_') }};
location / {
proxy_pass http://app_backend;
}
}
}The task:
- name: Deploy nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
lstrip_blocks: true # requirement 6
validate: nginx -t -c %s # requirement 7
backup: true
become: true
notify: Reload nginxThe seven decisions, each mapped to a requirement:
- {{ ansible_managed }}, kept static — no timestamp, or requirement 7 fails on the second run.
- groups['web'] | sort — without it, group ordering can vary and the file differs between runs, breaking requirement 7 in a way that looks inexplicable.
- is defined guard — requirement 3. hostvars[host]['ansible_facts']['default_ipv4'] is undefined for any host whose facts were not gathered, and one undefined variable aborts the whole template.
- [x, 16] | min — the idiomatic way to express a cap. [value, ceiling] | min reads better than a nested ternary.
- enable_tls | default(false) | bool — default for requirement 5, and | bool because -e enable_tls=false arrives as the string "false", which is truthy. Module 02 Part D5.
- lstrip_blocks: true with tags indented to match the structure — requirement 6. Without it, every {% if %} indent lands in the output.
- validate: — requirement 7's other half. A broken nginx config that reaches disk and triggers a reload takes the site down.
Verify:
ansible-playbook site.yml --check --diff --limit web01
ansible-playbook site.yml --limit web01
ansible-playbook site.yml --limit web01 # changed=0
ansible-playbook site.yml -e enable_tls=false --check --diff --limit web01E3 · Command reference — everything from this module
Working with templates
ansible-playbook site.yml --check --diff --limit web01 # ⭐ render and diff, change nothing
ansible localhost -m ansible.builtin.debug \
-a "msg={{ lookup('template', 'templates/x.j2') }}" # ⭐ quick syntax check
ansible-doc -t filter -l # ⭐ every filter available
ansible-doc -t filter ansible.builtin.combine # docs for one filter
ansible-doc -t test -l # every test (is defined, is version)
ansible-doc ansible.builtin.template # ⭐ all template module options
ansible localhost -m ansible.builtin.debug \
-a "msg={{ myvar | type_debug }}" # ⭐ string or int?The filters you will use constantly
# SAFETY
| default('x') # ⭐ fallback for undefined
| default('x', true) # ⭐ also replaces empty/falsy - strings only
| default(omit) # ⭐ drop the argument entirely
| mandatory # fail loudly if missing
| bool # ⭐ cast - essential for anything from -e
| int / | float / | string
| quote # ⭐ shell-escape - SECURITY
| type_debug # ⭐ diagnose type surprises
# STRINGS
| upper / | lower / | capitalize / | trim
| replace('a', 'b') # ⭐
| regex_replace('^v', '') # ⭐
| regex_search('[0-9]+')
| basename / | dirname # ⭐
| split(',') / | join(', ') # ⭐
# LISTS AND DICTS
| length / | first / | last / | unique / | sort # ⭐
| map(attribute='name') | list # ⭐ needs the trailing list
| selectattr('active') | list # ⭐
| rejectattr('name', 'equalto', 'root') | list
| combine(other, recursive=True) # ⭐ merge
| dict2items / | items2dict # ⭐
| dictsort # ⭐ stable ordering = idempotency
| union / | intersect / | difference
| min / | max / | sum
# DATA FORMATS
| to_nice_json(indent=2) / | to_nice_yaml(indent=2) # ⭐
| from_json / | from_yaml
| b64encode / | b64decode
| password_hash('sha512', salt) # needs a FIXED salt
| hash('sha256')
# MISC
| ternary('yes', 'no') # ⭐ inline conditional
| human_readable # 1048576 -> 1.00 MB
| urlsplit('hostname')Template module options
- ansible.builtin.template:
src: x.j2
dest: /etc/x.conf
mode: "0644" # ⭐ always quoted
validate: nginx -t -c %s # ⭐ test before going live
backup: true # ⭐ timestamped copy of the old file
lstrip_blocks: true # ⭐ strip indent before block tags
trim_blocks: true # Ansible's default
force: false # write only if absent
variable_start_string: "[[" # when the target format uses {{ }} itselfJinja2 syntax quick reference
{{ expression }} {# prints a value #}
{% statement %} {# control flow, prints nothing #}
{# comment #} {# stripped from the output #}
{% for h in groups['web'] | sort %} {# ⭐ sort for idempotency #}
{{ loop.index }} {{ loop.first }} {{ loop.last }} {# ⭐ loop.last for separators #}
{% else %} {# runs when the sequence is empty #}
{% endfor %}
{% if x %} {% elif y %} {% else %} {% endif %}
{% set ns = namespace(total=0) %} {# ⭐ accumulate across loop iterations #}
{%- tag %} / {% tag -%} {# ⭐ strip whitespace before / after #}
{% raw %}{{ literal }}{% endraw %} {# ⭐ pass braces through untouched #}
{% macro name(a, b=false) %}{% endmacro %}
{% import 'macros.j2' as m with context %}E4 · Official documentation
| Link | Covers |
|---|---|
| Templating (Jinja2) | How templating works in Ansible, native types, ansible_managed |
| Using filters to manipulate data | Every Ansible-provided filter with examples |
| Tests | is defined, is version, is match, is subset |
| Complex data manipulation | map, selectattr, subelements, json_query patterns |
| ansible.builtin.template module | Every option, including the delimiter overrides |
| Jinja2 template designer documentation | The upstream reference — macros, whitespace control, scoping rules |
| Jinja2 built-in filters | Filters that come from Jinja2 rather than Ansible |
| ansible.builtin.blockinfile | Managing a section inside a file you do not own |
E5 · Self-assessment
1. Where does a template render, and what follows from that?
On the control node. The target receives finished plain text and needs no templating library.
It follows that lookup() inside a template reads control-node files, and that a value existing only on the target must be captured with register first before a template can use it.
2. {{ }} vs {% %} vs {# #}?
Expression (prints a value), statement (control flow, prints nothing), comment (stripped entirely from the output).
3. Why does {{ users | map(attribute='name') }} render as <generator object ...>?
map is lazy and returns a generator. Add a trailing | list, or a consuming filter such as join, sort or length, which forces evaluation.
It fails silently — no error, just a Python repr string in your config file.
4. Name three causes of a template reporting changed on every run.
A timestamp in the content — including a customised ansible_managed. password_hash without a fixed salt. Unsorted iteration over a dictionary or a set, where ordering is not stable between runs. Also random without a seed.
Fix by making the render deterministic: | dictsort, | sort, a fixed salt, a static ansible_managed. Diagnose with --diff.
5. What is trim_blocks and what is Ansible's default?
It removes the first newline after a block tag. Ansible defaults it to true, unlike plain Jinja2 where it is false.
lstrip_blocks defaults to false and is the one you usually want to enable, so you can indent your tags to match the file structure without those indents appearing in the output.
6. How do you template a file that itself contains {{ }}?
{% raw %} ... {% endraw %} around the literal sections, or change the delimiters on the task with variable_start_string and block_start_string, or use copy if nothing actually needs substituting.
The common cases: Helm charts, Grafana dashboards, Prometheus rules, Vue templates.
7. Why does accumulating a total in a {% for %} loop fail?
Jinja2 scoping discards a {% set %} at the end of each iteration. Use a namespace() object, or — better in Ansible — replace the loop with a filter: {{ items | map(attribute='size') | sum }}.
8. What does validate: do, and name three places you must use it.
Runs a checker against the temporary file before it is moved into place, with %s substituted for the temp path. The task fails and the live file is untouched if validation fails.
sshd -t -f %s, visudo -cf %s, nginx -t -c %s — the three files most capable of locking you out or taking the site down.
9. What is jinja2_native?
A setting that makes Jinja2 expressions return real Python types instead of strings — so {{ 5 + 5 }} gives the integer 10 rather than "10".
Useful when passing structured data between tasks, but it changes behaviour project-wide and can break playbooks that relied on string coercion. A deliberate decision, not a quick fix.
10. template, copy, lineinfile, blockinfile — pick one for each case.
Own the file and it varies per host → template. Own it and it is static → copy. Need one line in a package-owned file → lineinfile. Need a managed section in a file you do not own → blockinfile.
The anti-pattern: many lineinfile tasks against a single file. Each is an independent regex that can drift, and it should have been a template.
You now have everything a role is made of: tasks, handlers, variables, templates and control flow. Module 05 covers packaging them — directory structure, defaults vs vars, dependencies, import_role vs include_role, and Galaxy.
📚 Sources for the interview questions
Behaviour verified against the current official Ansible templating documentation and the Jinja2 template designer documentation.
Question selection cross-referenced against publicly published 2026 Ansible interview question sets:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.