Mastering Jinja2 Templates in Ansible: Complete Guide with Real-World Examples

Jinja2 templating is one of Ansible’s most powerful features, allowing you to create dynamic configuration files that adapt to different environments, servers, and conditions. This comprehensive guide covers everything from basic variable substitution to advanced template techniques with real-world examples you can use in production.

What is Jinja2 Templating in Ansible?

Jinja2 is a modern templating engine for Python that Ansible uses to generate dynamic content. Instead of maintaining separate configuration files for each server or environment, you create one template that automatically adapts based on variables and conditions.

Why Use Templates?

  • Dynamic Configuration: Generate configs based on server facts (CPU, memory, IP address)
  • Environment-Specific: Same template works for dev, staging, and production
  • Reduced Duplication: One template instead of dozens of static files
  • Maintainability: Update one template instead of hunting through multiple files
  • Validation: Test template syntax before deployment

Basic Template Syntax

Variable Substitution

The most basic Jinja2 operation is variable substitution using double curly braces:

# Template file: app.conf.j2
server_name = {{ server_name }}
listen_port = {{ app_port }}
environment = {{ environment }}

When Ansible processes this template with variables:

vars:
  server_name: web01.example.com
  app_port: 8080
  environment: production

It generates:

server_name = web01.example.com
listen_port = 8080
environment = production

Accessing Ansible Facts

Ansible facts are system information automatically collected from target hosts. You can use these in templates:

# Template using facts
hostname = {{ ansible_hostname }}
ip_address = {{ ansible_default_ipv4.address }}
os = {{ ansible_distribution }} {{ ansible_distribution_version }}
cpu_cores = {{ ansible_processor_vcpus }}
total_memory = {{ ansible_memtotal_mb }}MB

Output on Ubuntu 20.04 server:

hostname = web01
ip_address = 192.168.1.10
os = Ubuntu 20.04
cpu_cores = 4
total_memory = 8192MB

Conditionals in Templates

Use {% if %} statements to include content conditionally:

Basic If/Else

# Template: database.conf.j2
[database]
host = {{ db_host }}
port = {{ db_port }}

{% if environment == 'production' %}
# Production settings
max_connections = 200
pool_size = 50
ssl_mode = require
{% else %}
# Development settings
max_connections = 20
pool_size = 5
ssl_mode = prefer
{% endif %}

If/Elif/Else

# Logging configuration based on environment
[logging]
{% if environment == 'production' %}
level = WARNING
debug = false
log_file = /var/log/app/production.log
{% elif environment == 'staging' %}
level = INFO
debug = false
log_file = /var/log/app/staging.log
{% else %}
level = DEBUG
debug = true
log_file = /var/log/app/development.log
{% endif %}

Inline Conditionals (Ternary)

For simple conditions, use inline ternary expressions:

cache_enabled = {{ 'true' if environment == 'production' else 'false' }}
debug_mode = {{ 'false' if environment == 'production' else 'true' }}
ssl_enabled = {{ 'yes' if ssl_certificate is defined else 'no' }}

Loops in Templates

Jinja2 loops allow you to iterate over lists and dictionaries to generate repetitive configuration blocks.

Simple For Loop

# Generate server list
{% for server in app_servers %}
server {{ server }}
{% endfor %}

With variables:

vars:
  app_servers:
    - web01.example.com
    - web02.example.com
    - web03.example.com

Generates:

server web01.example.com
server web02.example.com
server web03.example.com

Loop Over List of Dictionaries

# Nginx upstream configuration
upstream backend {
    {% for server in app_servers %}
    server {{ server.ip }}:{{ server.port }} weight={{ server.weight }};
    {% endfor %}
}

Variables:

vars:
  app_servers:
    - ip: 192.168.1.10
      port: 8080
      weight: 5
    - ip: 192.168.1.11
      port: 8080
      weight: 3
    - ip: 192.168.1.12
      port: 8080
      weight: 2

Result:

upstream backend {
    server 192.168.1.10:8080 weight=5;
    server 192.168.1.11:8080 weight=3;
    server 192.168.1.12:8080 weight=2;
}

Loop Variables

Jinja2 provides special variables inside loops:

{% for user in users %}
  User {{ loop.index }}: {{ user.name }}
  {% if loop.first %}(First user){% endif %}
  {% if loop.last %}(Last user){% endif %}
  Remaining: {{ loop.revindex }}
{% endfor %}

Loop variables available:

  • loop.index – Current iteration (1-indexed)
  • loop.index0 – Current iteration (0-indexed)
  • loop.first – True on first iteration
  • loop.last – True on last iteration
  • loop.length – Total number of items
  • loop.revindex – Iterations remaining (1-indexed)

Conditional Loops

Filter items in loops using conditions:

# Only active users
{% for user in users if user.active %}
  {{ user.name }} ({{ user.email }})
{% endfor %}

# Only admin users
{% for user in users if user.role == 'admin' %}
  Admin: {{ user.name }}
{% endfor %}

Complete Template Example: Nginx Load Balancer

Let’s create a comprehensive Nginx load balancer configuration template:

# Template: nginx_lb.conf.j2
# {{ ansible_managed }}
# Generated on {{ ansible_date_time.iso8601 }}

upstream {{ app_name }}_backend {
    {% for server in app_servers %}
    server {{ server.ip }}:{{ server.port }} weight={{ server.weight }} max_fails=3 fail_timeout=30s;
    {% endfor %}

    keepalive 32;
}

server {
    listen 80;
    server_name {{ ansible_fqdn }};

    access_log /var/log/nginx/{{ app_name }}_access.log;
    error_log /var/log/nginx/{{ app_name }}_error.log;

    location / {
        proxy_pass http://{{ app_name }}_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        
        {% if environment == 'production' %}
        proxy_connect_timeout 60s;
        proxy_send_timeout 60s;
        proxy_read_timeout 60s;
        {% else %}
        proxy_connect_timeout 300s;
        proxy_send_timeout 300s;
        proxy_read_timeout 300s;
        {% endif %}
    }

    location /health {
        access_log off;
        return 200 "healthy
";
        add_header Content-Type text/plain;
    }
    
    {% if environment == 'production' %}
    # Production-only rate limiting
    limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s;
    limit_req zone=one burst=20 nodelay;
    {% endif %}
}

# Backend server summary
{% for server in app_servers %}
# {{ server.name }}: {{ server.ip }}:{{ server.port }} (weight: {{ server.weight }})
{% endfor %}

Playbook to use this template:

- name: Deploy Nginx Load Balancer
  hosts: loadbalancers
  become: yes
  
  vars:
    app_name: myapp
    environment: production
    app_servers:
      - name: app1
        ip: 192.168.1.10
        port: 8080
        weight: 5
      - name: app2
        ip: 192.168.1.11
        port: 8080
        weight: 3

  tasks:
    - name: Deploy Nginx configuration
      template:
        src: templates/nginx_lb.conf.j2
        dest: /etc/nginx/sites-available/{{ app_name }}.conf
        mode: '0644'
        validate: 'nginx -t -c %s'
      notify: Reload Nginx

  handlers:
    - name: Reload Nginx
      service:
        name: nginx
        state: reloaded

Filters in Templates

Filters transform variables before outputting them. Syntax: {{ variable | filter }}

String Filters

# Convert case
{{ app_name | upper }}          # MYAPP
{{ app_name | lower }}          # myapp
{{ app_name | capitalize }}     # Myapp
{{ app_name | title }}          # My App

# String manipulation
{{ description | truncate(50) }}    # Truncate to 50 chars
{{ name | replace('old', 'new') }}  # Replace text
{{ path | basename }}               # Get filename from path
{{ string | trim }}                 # Remove whitespace

# Default values
{{ undefined_var | default('default_value') }}
{{ empty_string | default('fallback', true) }}  # true = treat empty as undefined

List Filters

# List operations
{{ servers | length }}           # Number of items
{{ servers | first }}            # First item
{{ servers | last }}             # Last item
{{ servers | join(', ') }}       # Join with delimiter
{{ servers | sort }}             # Sort list
{{ servers | reverse }}          # Reverse order
{{ servers | unique }}           # Remove duplicates

# Extract from list of dictionaries
{{ users | map(attribute='name') | list }}
{{ users | selectattr('active', 'equalto', true) | list }}
{{ users | rejectattr('admin') | list }}

Math Filters

# Arithmetic
{{ memory_mb / 1024 | round(2) }}    # 8.00 GB
{{ price | round(0, 'ceil') }}       # Round up
{{ value | abs }}                     # Absolute value
{{ number | int }}                    # Convert to integer
{{ value | float }}                   # Convert to float

# Memory conversions
{{ ansible_memtotal_mb | human_readable }}  # 8.0 GB

Type Conversion Filters

# Convert types
{{ '42' | int }}                 # String to integer
{{ 3.14 | string }}              # Number to string
{{ 'yes' | bool }}               # String to boolean
{{ data | to_json }}             # Dict to JSON
{{ data | to_nice_json }}        # Pretty JSON
{{ data | to_yaml }}             # Dict to YAML

Complete Application Configuration Example

# Template: app_config.j2
# {{ ansible_managed }}
# Configuration for {{ app_name }}
# Generated on {{ ansible_date_time.iso8601 }}

[application]
name = {{ app_name }}
version = {{ app_version }}
environment = {{ environment }}

[server]
hostname = {{ ansible_hostname }}
ip_address = {{ ansible_default_ipv4.address }}
os = {{ ansible_distribution }} {{ ansible_distribution_version }}
cpu_cores = {{ ansible_processor_vcpus }}
memory_gb = {{ (ansible_memtotal_mb / 1024) | round(1) }}

[database]
host = {{ database.host }}
port = {{ database.port }}
name = {{ database.name }}
user = {{ database.user }}
max_connections = {{ database.max_connections }}
pool_size = {{ (database.max_connections * 0.2) | int }}

[logging]
{% if environment == 'production' %}
level = WARNING
debug = false
log_file = /var/log/{{ app_name }}/production.log
max_size = 100MB
backup_count = 10
{% elif environment == 'staging' %}
level = INFO
debug = false
log_file = /var/log/{{ app_name }}/staging.log
max_size = 50MB
backup_count = 5
{% else %}
level = DEBUG
debug = true
log_file = /var/log/{{ app_name }}/development.log
max_size = 10MB
backup_count = 3
{% endif %}

[features]
cache_enabled = {{ 'true' if environment == 'production' else 'false' }}
debug_toolbar = {{ 'false' if environment == 'production' else 'true' }}
profiling = {{ 'disabled' if environment == 'production' else 'enabled' }}

[security]
ssl_enabled = {{ 'yes' if ssl_certificate is defined else 'no' }}
{% if ssl_certificate is defined %}
ssl_certificate = {{ ssl_certificate }}
ssl_key = {{ ssl_key }}
{% endif %}

[upstream_servers]
{% for server in app_servers %}
server_{{ loop.index }} = {{ server.ip }}:{{ server.port }} (weight: {{ server.weight }})
{% endfor %}

# System Information
# OS: {{ ansible_distribution }} {{ ansible_distribution_version }}
# Kernel: {{ ansible_kernel }}
# Python: {{ ansible_python_version }}
# Architecture: {{ ansible_architecture }}

Using Templates in Playbooks

Basic Template Task

- name: Deploy application configuration
  template:
    src: templates/app_config.j2
    dest: /etc/myapp/config.ini
    owner: root
    group: root
    mode: '0644'

Template with Validation

- name: Deploy Nginx config with validation
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    mode: '0644'
    validate: 'nginx -t -c %s'  # Validate before deploying
    backup: yes                  # Backup old file
  notify: Reload Nginx

Template with Variables

- name: Deploy user-specific config
  template:
    src: user_profile.j2
    dest: "/home/{{ item.username }}/.bashrc"
    owner: "{{ item.username }}"
    mode: '0644'
  loop: "{{ users }}"
  when: item.active

Advanced Template Techniques

Macros (Reusable Template Blocks)

# Define macro
{% macro render_server(server) %}
    server {
        listen {{ server.port }};
        server_name {{ server.name }};
        root {{ server.document_root }};
    }
{% endmacro %}

# Use macro
{% for server in vhosts %}
{{ render_server(server) }}
{% endfor %}

Whitespace Control

# Remove whitespace before
{%- if condition %}
content
{% endif %}

# Remove whitespace after
{% if condition -%}
content
{% endif %}

# Remove both
{%- if condition -%}
content
{%- endif -%}

Comments in Templates

{# This is a Jinja2 comment - won't appear in output #}

{# 
Multi-line
comment
#}

# This is a regular comment in the output file

Including Other Templates

# Include another template
{% include 'header.j2' %}

# Main content here

{% include 'footer.j2' %}

Real-World Example: System Report Generator

# Template: system_report.html.j2



    System Report - {{ ansible_hostname }}
    


    

System Information Report

Generated: {{ ansible_date_time.iso8601 }}

Server Details

PropertyValue
Hostname{{ ansible_hostname }}
FQDN{{ ansible_fqdn }}
OS{{ ansible_distribution }} {{ ansible_distribution_version }}
Kernel{{ ansible_kernel }}
CPU Cores{{ ansible_processor_vcpus }}
Memory{{ (ansible_memtotal_mb / 1024) | round(2) }} GB

Network Interfaces

{% for interface in ansible_interfaces %} {% if interface != 'lo' %} {% endif %} {% endfor %}
InterfaceIP AddressMAC Address
{{ interface }} {{ ansible_facts[interface]['ipv4']['address'] | default('N/A') }} {{ ansible_facts[interface]['macaddress'] | default('N/A') }}

Disk Usage

{% for mount in ansible_mounts %} {% endfor %}
MountTotalUsedAvailableUse %
{{ mount.mount }} {{ (mount.size_total / 1024 / 1024 / 1024) | round(2) }} GB {{ ((mount.size_total - mount.size_available) / 1024 / 1024 / 1024) | round(2) }} GB {{ (mount.size_available / 1024 / 1024 / 1024) | round(2) }} GB {{ ((mount.size_total - mount.size_available) / mount.size_total * 100) | round(2) }}%

Best Practices

1. Use ansible_managed Comment

Always start templates with the {{ ansible_managed }} variable:

# {{ ansible_managed }}
# This file is managed by Ansible - DO NOT EDIT MANUALLY

2. Validate Templates Before Deployment

- name: Deploy config with validation
  template:
    src: app.conf.j2
    dest: /etc/app/app.conf
    validate: '/usr/bin/app --check-config %s'

3. Use Backup Option

- name: Deploy critical config
  template:
    src: database.conf.j2
    dest: /etc/database/config.conf
    backup: yes  # Creates timestamped backup

4. Set Proper Permissions

- name: Deploy secure config
  template:
    src: secrets.conf.j2
    dest: /etc/app/secrets.conf
    mode: '0600'      # Read/write for owner only
    owner: appuser
    group: appgroup

5. Use Default Filters

Protect against undefined variables:

database_host = {{ db_host | default('localhost') }}
cache_ttl = {{ cache_ttl | default(3600) }}
admin_email = {{ admin_email | default('admin@example.com') }}

6. Keep Templates Simple

Complex logic belongs in playbooks, not templates:

# Good - Simple template
server_name = {{ server_name }}

# Bad - Complex logic in template
{% if ansible_distribution == "Ubuntu" and ansible_distribution_major_version >= "20" %}
  {% if environment == "production" and ssl_enabled %}
    # Complex nested logic...
  {% endif %}
{% endif %}

Troubleshooting

Issue: Undefined Variable Error

# Error: Variable 'db_host' is undefined

# Solution 1: Use default filter
database_host = {{ db_host | default('localhost') }}

# Solution 2: Check if defined
{% if db_host is defined %}
database_host = {{ db_host }}
{% endif %}

Issue: Template Syntax Error

# Validate template syntax before running playbook
ansible-playbook playbook.yml --syntax-check

# Check specific template
ansible all -m template -a "src=template.j2 dest=/tmp/test.conf" --check

Issue: Whitespace Issues

# Control whitespace with - modifier
{%- for item in list -%}
{{ item }}
{%- endfor -%}

Summary

Jinja2 templating in Ansible provides powerful dynamic configuration management:

  • Use {{ variable }} for substitution
  • Use {% if %} for conditionals
  • Use {% for %} for loops
  • Apply filters with {{ var | filter }}
  • Access facts with {{ ansible_* }}
  • Always validate templates before deployment
  • Keep templates simple and readable
  • Use default filters for undefined variables

Templates eliminate configuration duplication and adapt automatically to different environments, making your infrastructure code more maintainable and less error-prone.

Was this article helpful?

RS

About the Author: Ramesh Sundararamaiah

Red Hat Certified Architect

Ramesh is a Red Hat Certified Architect with extensive experience in enterprise Linux environments. He specializes in system administration, DevOps automation, and cloud infrastructure. Ramesh has helped organizations implement robust Linux solutions and optimize their IT operations for performance and reliability.

Expertise: Red Hat Enterprise Linux, CentOS, Ubuntu, Docker, Ansible, System Administration, DevOps

Add Comment