AnsibleXVII · Templates and Jinja2Templates and Jinja2
Jinja2 for sysadmins
What you'll learn
- Distinguish Jinja expressions from statements and use each correctly
- Explain where rendering happens and what the managed node ever sees
- Control whitespace with trim_blocks, lstrip_blocks and the minus operator
- Diagnose a rendered configuration file whose indentation is wrong
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
You have been using Jinja since the first playbook. {{ ansible_hostname }}
in a task argument is Jinja. This part is where it stops being string
interpolation and starts being a program that generates production
configuration files.
Start with the fact that determines everything else.
Rendering happens on the controller
The template module is an action plugin: part of it executes on
the controller before anything is sent anywhere. Verified on
ansible-core 2.21.3 — ansible-doc ansible.builtin.template reports
action: support: full, meaning “this has a corresponding action plugin
so some parts of the options can be executed on the controller”.
What that means concretely:
- The
.j2file is never copied to the managed node. It is read on the controller, rendered there, and the result is transferred. - Every variable, filter and lookup in the template resolves in the controller’s context, using the variables Ansible has assembled for that host.
- The managed node needs no Jinja, no Python templating library, and no awareness that a template was involved. It receives a file.
- A secret that appears in a rendered file existed as plaintext on the controller, in a temporary directory, before it was transferred. The secrets part returns to this.
- One template is rendered once per host, with that host’s variables. Four hundred hosts means four hundred renders and, potentially, four hundred different files.
Two delimiters, two jobs
Jinja has two constructs you will use constantly and one you will use occasionally.
| Syntax | Name | What it does |
|---|---|---|
{{ ... }} | expression | Evaluates and outputs the result |
{% ... %} | statement | Controls flow. Outputs nothing |
{# ... #} | comment | Removed entirely from the output |
The distinction is the one beginners get wrong: {% ... %} produces no
output. Writing {% workers %} where you meant {{ workers }} does not
print the value and does not error — on many Jinja constructs it is a
syntax error, but the general lesson is that the braces are not
interchangeable.
{# managed by Ansible - see roles/webserver/templates #}
upstream {{ app_name }}_backend {
{% for host in groups['appservers'] %}
server {{ hostvars[host]['ansible_default_ipv4']['address'] }}:{{ app_port }};
{% endfor %}
}
server {
listen {{ listen_port }};
server_name {{ inventory_hostname }};
{% if tls_enabled %}
ssl_certificate /etc/ssl/certs/{{ app_name }}.pem;
ssl_certificate_key /etc/ssl/private/{{ app_name }}.key;
{% endif %}
location / {
proxy_pass http://{{ app_name }}_backend;
}
}Three things in that template are worth naming because they are the things templates are actually for:
groups['appservers']— the inventory is available inside the template. The upstream list is generated from the fleet’s own membership rather than maintained by hand, which means adding a host to the group updates every proxy that references it.hostvars[host][...]— one host’s template can read another host’s facts. This is the mechanism behind almost every real configuration-generation task.{% if tls_enabled %}— a whole block appears or does not, driven by a variable that can be set per group.
Whitespace control, or why the config came out mangled
This is the source of most “the template is right but the file is wrong” questions, and it is entirely mechanical once you see it.
Jinja’s defaults in Ansible’s template module, verified on 2.21.3 via
ansible-doc:
| Option | Default in ansible.builtin.template |
|---|---|
trim_blocks | true |
lstrip_blocks | false |
trim_blocks: true removes the first newline after a block tag. That is
why {% for %} on its own line does not leave a blank line behind it.
Ansible has defaulted this to true since 0.9 and the module notes say
so.
lstrip_blocks: false means the leading whitespace before a block
tag is kept. That is the one that mangles files.
upstream app {
{% for h in backends %}
server {{ h }}:8080;
{% endfor %}
}$ ansible-playbook -i inv.ini ws.ymlTASK [Render with defaults (trim_blocks true, lstrip_blocks false)] ************
ok: [localhost] => {
"msg": "upstream app {\n server 192.0.2.11:8080;\n server 192.0.2.12:8080;\n }\n"
}The server lines came out with eight spaces, not four: four from
the indentation before {% for %}, which was emitted as literal text,
plus the four in front of server. And the closing brace picked up four
spaces from the line holding {% endfor %}.
For nginx that is cosmetic. For YAML, Python, a Makefile, a sudoers
fragment or anything else where indentation is syntax, it is a broken
file — and the template looks perfectly correct in your editor.
Three ways to fix it
upstream app {
{% for h in backends %}
server {{ h }}:8080;
{% endfor %}
}- name: Render the upstream configuration
ansible.builtin.template:
src: upstream.conf.j2
dest: /etc/nginx/conf.d/upstream.conf
mode: '0644'
lstrip_blocks: true
notify: Reload nginxVerified on 2.21.3 by setting the same option through the template
file’s own header — #jinja2: lstrip_blocks: True on the first line —
which renders the tidy-looking template correctly:
$ ansible-playbook -i inv.ini ws2.ymlTASK [lstrip_blocks via the jinja2 header] *************************************
ok: [localhost] => {
"msg": "upstream app {\n server 192.0.2.11:8080;\n server 192.0.2.12:8080;\n}\n"
}upstream app {
{%- for h in backends %}
server {{ h }}:8080;
{%- endfor %}
}Be careful with the third. {%- strips all whitespace before the
tag, including the preceding newline. Verified on 2.21.3, the template
above renders as a single line:
$ ansible-playbook -i inv.ini ws2.ymlTASK [whitespace control with the minus operator] ******************************
ok: [localhost] => {
"msg": "upstream app { server 192.0.2.11:8080; server 192.0.2.12:8080;}\n"
}Knowledge check
Knowledge check · 4 questions
Q1. Where is a .j2 template rendered, and what does the managed node receive?
Q2. A template indents its {% for %} and {% endfor %} tags by four spaces for readability. With the template module defaults, what does the rendered file look like?
Q3. Which of these are correct ways to stop indented block tags from adding whitespace? Select all that apply.
Q4. A template containing {{ ansible_date_time.iso8601 }} will cause its task to report changed on every run.
Passing score: 75%. Answers are checked in this browser.