Ansible Lesson 17 – Templates with Jinja2 | Dataplexa
Section II · Lesson 17

Templates with Jinja2

In this lesson

Jinja2 syntax Variables in templates Filters & tests Control structures Real-world templates

Jinja2 is a Python-based templating language that Ansible uses to render dynamic content in both template files and playbook expressions. When you write {{ variable }} in a playbook or a .j2 file, you are writing Jinja2. Templates extend this further — they allow entire configuration files to be generated dynamically, with values substituted from variables and facts, conditional blocks included or excluded per host, and loops used to generate repeated configuration sections. A single Jinja2 template can produce a perfectly tailored config file for every server in your fleet without any manual editing.

Jinja2 Syntax Overview

Jinja2 uses three delimiter types to distinguish template instructions from plain text. Everything outside these delimiters is output literally — only the content inside them is processed.

Expressions

{{ }}

Outputs the value of a variable, expression, or filter result. This is the most common delimiter — used in both playbooks and template files.

Statements

{% %}

Control flow statements — if, for, block, set. These do not produce output themselves; they control which output is generated.

Comments

{# #}

Template comments — stripped from the rendered output entirely. Use them to document template logic without the comments appearing in the deployed config file.

The ansible.builtin.template Module

Templates are rendered and deployed using ansible.builtin.template. It reads a .j2 file from the control node, renders it with the current host's variables and facts, and writes the result to the destination path on the managed node. Every template render is per-host — the same template produces different output for each server based on its facts and variables.

- name: Deploy Nginx configuration from template
  ansible.builtin.template:
    src: nginx.conf.j2          # template file on the control node (in templates/ dir)
    dest: /etc/nginx/nginx.conf # destination path on the managed node
    owner: root
    group: root
    mode: "0644"
    backup: true                # keep a backup of the previous version
  notify: Reload Nginx          # restart only if template output changed

The Mail Merge Analogy

A Jinja2 template works exactly like a mail merge in a word processor. You write one letter template with placeholders: "Dear {{ name }}, your order {{ order_id }} is ready." The system merges it with a list of recipients and produces a unique letter for each person. Ansible does the same with configuration files — one template, one run, a uniquely rendered config for every server in your fleet.

Variables and Expressions in Templates

All variables available in the play — inventory variables, group_vars, host_vars, facts, and play variables — are available inside a template. Reference them with {{ variable_name }} exactly as you would in a playbook task.

Template source — templates/nginx.conf.j2

{# Nginx main configuration — managed by Ansible. Do not edit manually. #}
{# Generated for: {{ inventory_hostname }} on {{ ansible_date_time.date }} #}

user {{ nginx_user | default('www-data') }};
worker_processes {{ ansible_processor_vcpus }};    {# auto-sized to CPU count #}
error_log /var/log/nginx/error.log {{ log_level | default('warn') }};
pid /run/nginx.pid;

events {
    worker_connections {{ nginx_worker_connections | default(1024) }};
}

http {
    server_name {{ ansible_fqdn }};

    listen {{ nginx_port | default(80) }};

    root {{ web_root | default('/var/www/html') }};
    index index.html index.htm;

    {# Include SSL config only for production #}
    {% if environment == 'production' %}
    ssl_certificate     /etc/ssl/certs/{{ ansible_fqdn }}.crt;
    ssl_certificate_key /etc/ssl/private/{{ ansible_fqdn }}.key;
    {% endif %}

    access_log /var/log/nginx/{{ ansible_hostname }}-access.log;
}

Rendered output on web01.example.com (production, 4 vCPUs)

# Nginx main configuration — managed by Ansible. Do not edit manually.
# Generated for: web01.example.com on 2024-11-14

user www-data;
worker_processes 4;
error_log /var/log/nginx/error.log warn;
pid /run/nginx.pid;

events {
    worker_connections 1024;
}

http {
    server_name web01.example.com;

    listen 80;

    root /var/www/html;
    index index.html index.htm;

    ssl_certificate     /etc/ssl/certs/web01.example.com.crt;
    ssl_certificate_key /etc/ssl/private/web01.example.com.key;

    access_log /var/log/nginx/web01-access.log;
}

What just happened?

The template rendered a unique config file for this specific host: {{ ansible_processor_vcpus }} became 4, {{ ansible_fqdn }} became web01.example.com, and the SSL block was included because environment == 'production'. Running the same template against a 2-vCPU staging server would produce a different, correctly sized config automatically — no manual editing required.

Jinja2 Filters

Filters transform a value using the pipe | syntax: {{ value | filter_name }}. They are one of Jinja2's most powerful features — Ansible ships with dozens of built-in filters plus all of Jinja2's standard set. Filters can convert types, manipulate strings, handle missing values, format data, and much more.

🛡️

default(value)

Returns a fallback value when the variable is undefined or empty. Essential for optional variables — {{ nginx_port | default(80) }} uses 80 if nginx_port is not defined anywhere.

🔢

int, float, bool, string

Type conversion filters. Use | int when arithmetic on a variable that may be a string, or | bool when a variable might be the string "true" rather than the boolean true.

✏️

upper, lower, title, capitalize

String case transformations. Use | upper for environment names in config headers, | lower for normalising user input, or | title for display strings.

📋

join(separator)

Joins a list into a single string with a separator. {{ allowed_ips | join(', ') }} turns ['10.0.0.1', '10.0.0.2'] into 10.0.0.1, 10.0.0.2 — perfect for allowlist config lines.

📐

regex_replace(pattern, replacement)

Replaces regex matches in a string. Useful for sanitising hostnames, transforming version strings, or normalising paths. {{ hostname | regex_replace('\.', '-') }} converts dots to dashes.

🔐

b64encode / b64decode

Base64 encode or decode a value. Common when generating Kubernetes secrets, encoding credentials for API payloads, or embedding binary content in text configuration files.

🔀

to_yaml / to_json / to_nice_json

Serialise a variable to YAML or JSON format. Invaluable for generating structured config files — pass a dict variable and render it as a properly formatted YAML or JSON block with one filter.

Filters in action

{# default — safe fallback for optional variables #}
max_connections = {{ db_max_connections | default(100) }}

{# int — arithmetic on a variable that might be a string #}
worker_threads = {{ ansible_processor_vcpus | int * 2 }}

{# join — list to config-friendly string #}
allowed_hosts = {{ allowed_hosts | join(' ') }}

{# upper — normalise environment name for a header comment #}
# Environment: {{ environment | upper }}

{# Chaining multiple filters #}
log_path = /var/log/{{ app_name | lower | regex_replace(' ', '_') }}.log

{# to_nice_json — render a dict variable as formatted JSON #}
{
  "database": {{ db_config | to_nice_json }}
}

Control Structures in Templates

Templates support full if/elif/else blocks and for loops using the {% %} delimiter. These control structures let a single template generate fundamentally different config content depending on the host's role, environment, or any variable value.

Conditional blocks — if / elif / else / endif

{# templates/app.conf.j2 #}

[server]
bind_address = {{ ansible_default_ipv4.address }}
port = {{ app_port | default(8080) }}

[logging]
{% if log_level == 'debug' %}
level = debug
file = /var/log/app/debug.log
rotate_daily = true
max_files = 30
{% elif log_level == 'warn' %}
level = warn
file = /var/log/app/app.log
rotate_daily = true
max_files = 7
{% else %}
level = info
file = /var/log/app/app.log
rotate_daily = false
{% endif %}

[features]
{% if environment == 'production' %}
debug_toolbar = false
profiling = false
minify_assets = true
{% else %}
debug_toolbar = true
profiling = true
minify_assets = false
{% endif %}

Loops in templates — for / endfor

{# templates/haproxy.cfg.j2 — generate backend entries for all web servers #}

frontend http_front
    bind *:80
    default_backend http_back

backend http_back
    balance roundrobin
{% for host in groups['webservers'] %}
    server {{ host }} {{ hostvars[host]['ansible_default_ipv4']['address'] }}:80 check
{% endfor %}

{# Rendered output when webservers = [web01, web02, web03]: #}
{# backend http_back                                          #}
{#     balance roundrobin                                     #}
{#     server web01 192.168.1.10:80 check                    #}
{#     server web02 192.168.1.11:80 check                    #}
{#     server web03 192.168.1.12:80 check                    #}

What just happened?

The HAProxy template looped over the webservers inventory group using groups['webservers'] and looked up each server's IP from hostvars. Add a new server to the inventory and re-run the playbook — the HAProxy config is regenerated with the new backend entry automatically, with no manual editing. This is the template pattern that eliminates a whole category of configuration drift.

Real-World Template Scenario

The scenario: A DevOps team manages a three-tier application — web, app, and database servers — across two environments: staging and production. Each tier needs a different configuration file, and each environment needs different values for logging, resource limits, and security settings. Rather than maintaining six separate config files, they use three Jinja2 templates and variable files per environment.

Project structure

myproject/
├── templates/
│   ├── nginx.conf.j2          # web tier template
│   ├── app.conf.j2            # app tier template
│   └── pg_hba.conf.j2         # database access control template
├── inventory/
│   ├── staging/
│   │   └── group_vars/
│   │       ├── all.yml        # environment: staging, log_level: debug
│   │       └── webservers.yml # nginx_port: 8080
│   └── production/
│       └── group_vars/
│           ├── all.yml        # environment: production, log_level: warn
│           └── webservers.yml # nginx_port: 443
└── deploy.yml

Database access control template — templates/pg_hba.conf.j2

{# pg_hba.conf — PostgreSQL client authentication #}
{# Managed by Ansible — do not edit manually          #}
{# Host: {{ inventory_hostname }}                     #}

# TYPE  DATABASE  USER      ADDRESS              METHOD

# Local connections always allowed
local   all       postgres                        peer

{% if environment == 'production' %}
{# Production: only allow connections from app servers #}
{% for app_host in groups['appservers'] %}
host    app_db    appuser   {{ hostvars[app_host]['ansible_default_ipv4']['address'] }}/32   md5
{% endfor %}
{% else %}
{# Staging: allow connections from entire app subnet for developer access #}
host    all       all       {{ app_subnet | default('10.0.0.0/8') }}   md5
{% endif %}

# Deny everything else
host    all       all       0.0.0.0/0             reject

In production, this template generates a precise allowlist containing only the IP addresses of the current app servers — pulled live from hostvars. In staging, it opens up to the whole subnet for developer convenience. The same template, the same playbook, two completely different security postures — controlled entirely by the value of environment in each inventory's group_vars/all.yml.

Always Add a "Managed by Ansible" Header to Every Generated Config File

When Ansible manages a config file, manual edits to that file will be silently overwritten the next time the playbook runs. This is by design — but it causes real incidents when engineers do not know which files are template-managed. Add a comment to the top of every .j2 template: "This file is managed by Ansible. Do not edit manually — changes will be overwritten." This single habit prevents hours of confused debugging.

Key Takeaways

Templates render per-host — the same .j2 file produces unique output for every managed node based on that host's facts and variables. A fleet of 100 servers gets 100 correctly tailored config files from one template.
Always use | default(value) for optional variables — a template that references an undefined variable fails at render time. Defensive defaults make templates robust against missing variables.
Use groups and hostvars in templates — these magic variables give templates access to the entire inventory, enabling config files that reference other hosts (load balancer backends, database allowlists, monitoring targets).
The backup: true option on the template module saves the previous version — when a template-deployed config causes a problem, you can restore the previous version instantly without needing a rollback playbook.
Add a "managed by Ansible" header to every template — it prevents engineers from manually editing a file that will be silently overwritten on the next playbook run.

Teacher's Note

Take any config file you currently manage by hand — an Nginx vhost, a cron definition, a database client config — and convert it to a Jinja2 template. Replace every hostname, port, and path with a variable. Run it once and watch the rendered output. That moment of seeing a machine-generated config that is identical to your hand-written one is the point where templates click.

Practice Questions

1. What file extension convention does Ansible use for Jinja2 template files?



2. Which Jinja2 filter provides a fallback value when a variable is undefined — preventing a template render failure?



3. Inside a Jinja2 template, which magic variable gives you access to the full list of hosts in any inventory group — enabling templates to loop over other tiers like webservers or appservers?



Quiz

1. What does ansible.builtin.template do differently from ansible.builtin.copy?


2. You are writing an HAProxy config template that needs to include a backend server line for every host in the webservers group. What Jinja2 pattern achieves this?


3. A template references {{ custom_port }} but custom_port is not defined in any variable file or inventory for this host. What happens?


Up Next · Lesson 18

File and Package Management

Master Ansible's file and package modules in depth — creating directory trees, managing permissions, installing from multiple sources, and handling package version pinning across your fleet.