Jinja2 Foundations
What Jinja2 is
Jinja2 is a template language. It lets you build text files dynamically using variables and logic. Ansible uses it extensively in the template module to render config files before deploying them to hosts.
A Jinja2 template is a plain text file (typically ending in .j2) containing a mix of static text and Jinja2 expressions.
Why it matters
Instead of writing 10 nearly identical config files by hand, you write one template and feed in different variables for each host or environment. The template renders the correct file for each target.
Variable output
Use double curly braces to output a variable's value:
{{ nginx_port }}
{{ inventory_hostname }}
{{ ansible_default_ipv4.address }}
Anything inside {{ }} is evaluated and replaced with its value when the template renders.
Conditionals
{% if enable_tls %}
listen 443 ssl;
{% else %}
listen 80;
{% endif %}
What this means: if enable_tls is true, use the HTTPS listener; otherwise use plain HTTP.
{% if %} must have a matching {% endif %}. Missing it is one of the most common Jinja2 errors.
Loops
{% for server in ntp_servers %}
server {{ server }} iburst
{% endfor %}
This renders one server ... iburst line for each item in the ntp_servers list.
Filters
Filters transform values. Apply them with a pipe character |:
{{ username | lower }}
{{ value | default('none') }}
{{ items | join(', ') }}
{{ path | basename }}
{{ number | int }}
Common filters:
lower/upper— change casedefault('fallback')— use fallback if variable is undefined or emptyjoin(', ')— join a list into a single stringint/float— convert typebasename— get filename from a pathtrim— strip leading and trailing whitespace
Important difference: YAML vs Jinja2
- YAML defines data structure — what the variables are and what they contain
- Jinja2 renders dynamic text — turns variables into output
People often confuse the two because Ansible uses both in the same project. YAML is for playbooks, inventory, and variable files. Jinja2 is for template files and inside quoted strings in tasks.
Example template
An nginx config template (nginx.conf.j2):
user nginx;
worker_processes auto;
events {
worker_connections 1024;
}
http {
server {
listen {{ nginx_port }};
server_name {{ server_name }};
{% if enable_tls %}
ssl_certificate /etc/ssl/certs/{{ server_name }}.crt;
ssl_certificate_key /etc/ssl/private/{{ server_name }}.key;
{% endif %}
}
}
Deployed with an Ansible task:
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
Common mistakes
- Confusing YAML syntax with Jinja2 syntax inside the same file
- Forgetting
{% endif %}or{% endfor %} - Using a variable that was never defined — use
default()as a guard - Unexpected whitespace from control blocks — use
{%- -%}to strip whitespace if needed - Quoting issues when Jinja2 expressions appear inside YAML string values
Good habits
- Keep template logic simple — put complex decisions in variables or tasks
- Use
default()defensively to avoid undefined variable errors - Inspect rendered output with
--diffbefore applying - Name template files clearly with a
.j2extension
Ansible magic variables
Ansible automatically provides a set of special variables in every template and task. You do not define them — Ansible populates them from the inventory and runtime context.
| Variable | What it contains |
|---|---|
| inventory_hostname | The name of the current host as written in your inventory |
| ansible_default_ipv4.address | The primary IPv4 address of the current host |
| groups['groupname'] | List of all hosts in the named inventory group |
| hostvars['hostname'] | All Ansible facts and variables for any other host |
Example: render a config line for every host in a group, using their IP addresses:
# In a Jinja2 template (.j2 file)
# Builds an allow-list of all backend server IPs
{% for host in groups['backends'] %}
allow {{ hostvars[host]['ansible_default_ipv4']['address'] }};
{% endfor %}
This loops over every host in the backends inventory group and writes an allow line per host using that host's primary IP. Ansible collects the IPs automatically during the play via fact-gathering.
ansible_default_ipv4 to be populated. If you turned off gathering with gather_facts: false, use hostvars[host]['ansible_host'] instead (the inventory address).
Jinja2 tests (is defined, is none…)
Jinja2 tests are used with the is keyword to check a value's state or type. They are cleaner and more explicit than relying on truthiness checks.
# is defined — the most useful test in Ansible templates
{% if db_password is defined %}
password={{ db_password }}
{% endif %}
# is none — check explicitly for None/null (different from undefined!)
{% if proxy_url is none %}
# no proxy configured
{% else %}
proxy={{ proxy_url }}
{% endif %}
# is string / is number / is iterable
{% if listen_ports is iterable and listen_ports is not string %}
{% for port in listen_ports %}
listen {{ port }};
{% endfor %}
{% else %}
listen {{ listen_ports }};
{% endif %}
# is mapping (dict check)
{% if extra_headers is mapping %}
{% for key, value in extra_headers.items() %}
add_header {{ key }} "{{ value }}";
{% endfor %}
{% endif %}
| Test | True when… |
|---|---|
is defined | Variable exists (not undefined) |
is undefined | Variable does not exist |
is none | Value is explicitly null |
is string | Value is a string |
is number | Value is int or float |
is iterable | Value can be iterated (list, dict, string) |
is mapping | Value is a dict |
is sequence | Value is a list or string |
Key Ansible filters
Ansible adds many filters on top of standard Jinja2. These are the ones you will see most often in real infrastructure templates.
ternary — inline if/else
# value | ternary(true_val, false_val)
listen_ssl: "{{ (enable_tls | bool) | ternary('443', '80') }}"
max_workers: "{{ (env == 'production') | ternary(16, 4) }}"
regex_replace — transform strings
safe_hostname: "{{ inventory_hostname | regex_replace('\\.', '_') }}"
# web01.internal.example.com → web01_internal_example_com
quote — shell safety
# Always quote variables used in shell commands
- name: Run script with user input
ansible.builtin.shell: "/usr/bin/process {{ user_input | quote }}"
selectattr / rejectattr — filter a list of dicts
# users is a list of dicts with 'name', 'state', 'groups' keys
# Get only active users
active_users: "{{ users | selectattr('state', 'equalto', 'present') | list }}"
# Get users who are in the admin group
admins: "{{ users | selectattr('groups', 'contains', 'wheel') | list }}"
# Exclude disabled users
enabled: "{{ users | rejectattr('state', 'equalto', 'absent') | list }}"
dict2items / items2dict — convert between dict and list
# dict2items — iterate a dict as a list of {key, value} pairs
env_vars:
APP_ENV: production
LOG_LEVEL: warn
DB_HOST: db01.internal
# In template:
{% for item in env_vars | dict2items %}
{{ item.key }}={{ item.value }}
{% endfor %}
# Renders:
# APP_ENV=production
# LOG_LEVEL=warn
# DB_HOST=db01.internal
# items2dict — rebuild a dict from a list
# Useful when group_vars has a list but you need key lookup
all_vars_dict: "{{ all_vars_list | items2dict(key_name='name', value_name='value') }}"
{% set %} template variables
Use {% set %} to create a local variable inside a template. This avoids repeating complex expressions and makes templates easier to read.
# Calculate a value once and reuse it
{% set max_conn = (ansible_memtotal_mb / 4) | int %}
max_connections = {{ max_conn }}
shared_buffers = {{ (max_conn * 8) }}MB
# Build a string conditionally
{% set ssl_dir = '/etc/pki/tls' if ansible_os_family == 'RedHat' else '/etc/ssl' %}
ssl_certificate {{ ssl_dir }}/certs/{{ inventory_hostname }}.crt;
ssl_certificate_key {{ ssl_dir }}/private/{{ inventory_hostname }}.key;
# Collect filtered items into a variable
{% set active_vhosts = vhosts | selectattr('enabled', 'equalto', true) | list %}
# Active vhosts: {{ active_vhosts | length }}
{% for vhost in active_vhosts %}
server_name {{ vhost.name }};
{% endfor %}
{% set %} variables are scoped to the current template. They do not persist between templates or back to the playbook — use set_fact for that.