Ansible Course
Templates with Jinja2
In this lesson
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.
{{ }}
Outputs the value of a variable, expression, or filter result. This is the most common delimiter — used in both playbooks and template files.
{% %}
Control flow
statements — if, for, block, set.
These do not produce output themselves; they control which output is generated.
{# #}
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
.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.
| default(value) for optional variables
— a template that references an undefined variable fails at render time.
Defensive defaults make templates robust against missing variables.
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).
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.
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.