Skip to content

Templates & Jinja2

A template is a config file with placeholders that Ansible fills in per host before placing it on the machine. It's how one docker-compose.yml.j2 becomes a correct, host-specific compose file on every node. Templates use the Jinja2 templating language and are conventionally named *.j2 and kept in a role's templates/ directory.

# In a task — render templates/borgmatic-config.yml.j2 to the host
- name: Template borgmatic config
  ansible.builtin.template:
    src: borgmatic-config.yml.j2
    dest: /etc/borgmatic/config.yaml
    mode: "0600"

The three Jinja2 delimiters

Syntax Purpose Example
{{ ... }} Print a value server: {{ docker_dns }}
{% ... %} Logic (loops, if) {% for d in dirs %}
{# ... #} Comment (not rendered) {# TODO: revisit #}

Variables

Anything available to the task — group_vars/host_vars, facts, registered vars — is usable in the template.

# templates/app.conf.j2
timezone = {{ timezone }}
data_dir = {{ appdata_dir }}/app
hostname = {{ inventory_hostname }}
cpus = {{ ansible_processor_vcpus }}          {# a fact #}

Access nested values with dots or brackets:

first_backup = {{ borg_backup_targets[0] }}
os = {{ ansible_distribution }} {{ ansible_distribution_version }}

Filters — transform a value with |

Filters are the workhorses of Jinja2. Chain them with |.

{# Provide a fallback when a variable might be undefined #}
port = {{ app_port | default(8080) }}

{# Fail loudly if a required var is missing #}
token = {{ vault_api_token | mandatory }}

{# Text #}
name = {{ main_user | upper }}
slug = {{ app_name | lower | replace(' ', '-') }}

{# Lists #}
packages = {{ base_packages | join(', ') }}
count = {{ base_packages | length }}
first = {{ base_packages | first }}

{# Numbers / bytes #}
half = {{ ansible_memtotal_mb | int // 2 }}

{# JSON / YAML output (great for config files) #}
config = {{ my_dict | to_nice_json }}
Filter Does
default(x) Use x if the variable is undefined
default(x, true) Use x if undefined or empty/false
mandatory Error out if undefined
join(', ') List → string
length, first, last List helpers
upper / lower / replace(a,b) Text
int / float Type conversion
to_nice_json / to_nice_yaml Serialize a dict/list
bool Interpret as true/false

Loops

Repeat lines for each item in a list — the pattern behind the borgmatic and compose templates in this repo.

# templates/borgmatic-config.yml.j2
source_directories:
{% for dir in borg_backup_targets %}
  - {{ dir }}
{% endfor %}

With a list of dictionaries:

{% for user in app_users %}
  - name: {{ user.name }}
    role: {{ user.role | default('member') }}
{% endfor %}

loop.index (1-based) and loop.first / loop.last are handy inside loops:

{% for host in groups['apps'] %}
server{{ loop.index }} = {{ host }}{% if not loop.last %},{% endif %}
{% endfor %}

Conditionals

{% if ansible_memtotal_mb > 8000 %}
worker_processes = 4
{% elif ansible_memtotal_mb > 4000 %}
worker_processes = 2
{% else %}
worker_processes = 1
{% endif %}

{# Inline conditional #}
log_level = {{ 'debug' if debug_enabled | default(false) else 'info' }}

{# Only include a block when a variable is set #}
{% if custom_dns is defined %}
dns = {{ custom_dns }}
{% endif %}

Whitespace control

{% for %}/{% if %} lines leave blank lines behind in the output. Add a - to trim surrounding whitespace — important for clean YAML/config files.

repositories:
{% for repo in repos -%}
  - {{ repo }}
{% endfor -%}
  • {%- trims whitespace before the tag.
  • -%} trims the newline after the tag.

Preview a template before trusting it

Run the play in check + diff mode to see exactly what the rendered file will look like without writing it:

ansible-playbook playbooks/base.yml --check --diff --limit ops-01.internal

A real example

# templates/homepage-env.j2  — Managed by Ansible, do not edit on the host
HOMEPAGE_ALLOWED_HOSTS={{ inventory_hostname }}.internal,{{ ansible_default_ipv4.address }}
PUID={{ puid | default(1000) }}
PGID={{ pgid | default(1000) }}
TZ={{ timezone }}
{% for var in extra_env | default([]) %}
{{ var.key }}={{ var.value }}
{% endfor %}

Always mark generated files so future-you doesn't hand-edit them:

# Managed by Ansible -- changes here are overwritten on the next run

Ref: Ansible — Templating (Jinja2) · Jinja2 docs