Skip to main content
RunBook Academy

AnsibleXVII · Templates and Jinja2Templates and Jinja2

Jinja2 for sysadmins

Intermediate⏱ ~20 minansible-playbook

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

Not yet marked complete on this device.

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 .j2 file 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.

SyntaxNameWhat it does
{{ ... }}expressionEvaluates and outputs the result
{% ... %}statementControls flow. Outputs nothing
{# ... #}commentRemoved 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.

Read-only / Safea small but real template
{# 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:

OptionDefault in ansible.builtin.template
trim_blockstrue
lstrip_blocksfalse

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.

Read-only / Safethe template that looks tidy
upstream app {
  {% for h in backends %}
  server {{ h }}:8080;
  {% endfor %}
}
Read-only / Safewhat it actually renders
$ ansible-playbook -i inv.ini ws.yml
TASK [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

Read-only / Safe1. do not indent the block tags
upstream app {
{% for h in backends %}
  server {{ h }}:8080;
{% endfor %}
}
Read-only / Safe2. turn on lstrip_blocks for the whole template
- 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 nginx

Verified 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:

Read-only / Safethe same template with lstrip_blocks enabled
$ ansible-playbook -i inv.ini ws2.yml
TASK [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"
}
Read-only / Safe3. the minus operator, per tag
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:

Read-only / Safethe minus operator, overapplied
$ ansible-playbook -i inv.ini ws2.yml
TASK [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

  1. Q1. Where is a .j2 template rendered, and what does the managed node receive?

  2. 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?

  3. Q3. Which of these are correct ways to stop indented block tags from adding whitespace? Select all that apply.

  4. 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.