Template (Jinja2) #
In automated infrastructure development, flexibility is the key. Writing static configuration files for every server isn’t practical because each node often needs unique settings, like different IP addresses, memory allocation adjusted to RAM capacity, or varying licenses. Ansible solves this problem by providing the ansible.builtin.template module that leverages the Jinja2 templating engine. With Jinja2, you can write one master configuration template and dynamically generate the final configuration file precisely tailored for each target server.
Basic Principles and Jinja2 Templating Syntax #
The template module works by reading a source file on the control node (usually a text file with a .j2 extension), rendering all variables and logic expressions inside it using data from inventory, facts, or variables, then sending the rendered text file to the managed node.
The Jinja2 templating engine uses three main delimiter types to separate template code from plain text:
{{ ... }}(Expressions): Used to print variable values or expression results into the output file. Example:{{ db_port }}.{% ... %}(Statements): Used to control flow logic like loops, conditionals, and macro calls. Example:{% if ssl_enabled %}.{# ... #}(Comments): Used to write internal template comments. Everything inside these delimiters isn’t processed and doesn’t appear in the final configuration file on the target server.
It’s important to remember that Jinja2 rendering happens entirely on your local machine (control node) before the file is sent via SSH. This means you can use all the data collected during the Gathering Facts phase to influence the final configuration file result.
Whitespace and Empty Space Control #
By default, Jinja2 preserves every newline and space around {% ... %} statement blocks. This often produces final configuration files with many empty lines or messy indentation, especially after running for loops or if conditionals.
To fix this visual problem, Jinja2 provides a whitespace control mechanism using the minus character (-). You can insert a minus at the start of a statement ({%-) or at the end (-%}) to remove empty spaces and newlines on the left or right side of that block.
Let’s compare the output difference between wrong and correct writing:
{# ANTI-PATTERN: Loop without whitespace control produces empty lines that break the configuration file #}
upstream backend_app {
{% for host in groups['webservers'] %}
server {{ host }}:8080;
{% endfor %}
}
{# CORRECT: Using minus (-) to remove whitespace at the start/end of the loop block #}
upstream backend_app {
{%- for host in groups['webservers'] %}
server {{ host }}:8080;
{%- endfor %}
}
If you use the anti-pattern approach above, your final file looks messy like this:
upstream backend_app {
server web-01:8080;
server web-02:8080;
}
However, by using the {%- and -%} whitespace control, Jinja2 tightens the empty spaces around the statements and produces clean, standard output:
upstream backend_app {
server web-01:8080;
server web-02:8080;
}
Conditional Expressions in Jinja2 #
Conditional branching using if, elif, and else lets you create adaptive configuration based on server hardware specs or deployment environment types (staging vs production).
Here’s an example of an Nginx vhost configuration that dynamically enables SSL and adjusts performance based on the target server’s memory:
server {
listen {{ nginx_port | default(80) }};
server_name {{ domain_name }};
{# Conditionally enabling SSL configuration #}
{% if ssl_enabled | bool %}
listen 443 ssl;
ssl_certificate {{ ssl_cert_path }};
ssl_certificate_key {{ ssl_key_path }};
{% endif %}
{# Adjusting database connections based on the server's RAM memory #}
{% if ansible_memtotal_mb > 8000 %}
# Configuration for high-capacity servers (> 8GB RAM)
pm.max_children = 50
pm.start_servers = 10
{% elif ansible_memtotal_mb > 2000 %}
# Configuration for mid-range servers (2GB - 8GB RAM)
pm.max_children = 20
pm.start_servers = 5
{% else %}
# Minimal configuration for micro servers (< 2GB RAM)
pm.max_children = 5
pm.start_servers = 2
{% endif %}
}
In the example above, the ansible_memtotal_mb data, which is a built-in Linux system fact, is read directly by Jinja2 to automatically calculate PHP-FPM configuration without manual intervention.
Loops and Special Loop Variables #
for loops are used to generate repeated configuration lines from a list or dictionary data. Inside a for loop block, Jinja2 provides a special helper variable named loop that stores the current loop state:
loop.index: The current index number, starting from 1.loop.index0: The current index number, starting from 0.loop.first:trueif the loop is at the first element.loop.last:trueif the loop is at the last element.loop.length: The total number of elements in the loop.
The loop.last variable is very helpful when creating JSON, YAML, or configuration line formats that separate elements with commas, where you want to make sure the last element doesn’t end with a comma to avoid syntax errors.
{# Generating a JSON configuration file without a trailing comma at the end of the array #}
{
"database_nodes": [
{%- for host in groups['dbservers'] %}
{
"hostname": "{{ host }}",
"ip": "{{ hostvars[host]['ansible_default_ipv4']['address'] }}"
}{% if not loop.last %},{% endif %}
{%- endfor %}
]
}
Without the {% if not loop.last %},{% endif %} check, Jinja2 adds a comma after the last element, which triggers a JSON parser error because standard JSON format forbids trailing commas.
Jinja2 Filters: default, ipaddr, join, and bool #
Filters are utility functions used to change the format or process variable values before printing. You use the pipe character (|) to call filters. Here are the four most important filters in Ansible configuration:
1. The default Filter
#
Used to set a fallback value if a variable isn’t defined. If you want to force a failure when the variable is empty, you can use the custom mandatory filter.
# If app_port isn't specified in inventory, use port 8000
port = {{ app_port | default(8000) }}
# If db_password isn't specified, stop rendering with a clear error
db_password = {{ db_password | mandatory }}
2. The bool Filter
#
Converts string representations (like "yes", "true", "1", or "on") into Python boolean data types (True or False). This is crucial because Ansible often reads user input values as plain strings.
# Forcing a safe boolean evaluation
{% if enable_debug | default('false') | bool %}
log_level = DEBUG
{% else %}
log_level = WARNING
{% endif %}
3. The join Filter
#
Joins elements in a list into one single string with a specific separator.
# Converting ['10.0.1.1', '10.0.1.2'] into "10.0.1.1,10.0.1.2"
allowed_hosts = "{{ acl_hosts | join(',') }}"
4. The ipaddr Filter
#
A very powerful filter for validating and manipulating IP addresses and subnet masks. This filter requires the Python netaddr library installed on your control node.
# Checking whether the variable contains a valid IP address
{% if my_ip | ipaddr %}
ip_address = {{ my_ip | ipaddr('address') }}
subnet_mask = {{ my_ip | ipaddr('netmask') }}
network_range = {{ my_ip | ipaddr('network') }}/{{ my_ip | ipaddr('prefix') }}
{% endif %}
Here’s a summary of important filters along with example use cases:
| Filter Name | Input Variable | Filter Syntax | Output Result | Use Case |
|---|---|---|---|---|
default | undefined | {{ port | default(80) }} | 80 | Default port fallback |
bool | "yes" | {% if debug | bool %} | True (Boolean) | Safe condition evaluation |
join | ['a', 'b'] | {{ list | join(':') }} | "a:b" | Host list separator |
ipaddr | "192.168.1.1/24" | {{ ip | ipaddr('netmask') }} | "255.255.255.0" | Subnet mask calculation |
Cross-Host Integration with hostvars #
One of Ansible’s most powerful features is the ability to access data from other servers in the middle of rendering a template for the current server. You do this using the global hostvars variable.
For example, when deploying an HAProxy load balancer, you need to know the IP addresses of all your backend web servers. You can loop over the webservers server group in the inventory and read each server’s IP variable using hostvars.
flowchart TD
subgraph "Managed Nodes (Backends)"
W1["web-01 (IP: 10.0.1.10)"]
W2["web-02 (IP: 10.0.1.11)"]
end
subgraph "Control Node"
A["Ansible gathers facts from web-01 & web-02"] --> B["Ansible renders haproxy.cfg.j2 using hostvars"]
end
subgraph "Load Balancer"
C["HAProxy Server"]
end
B -- "template deploy" --> C
C -->|"Route Traffic"| W1
C -->|"Route Traffic"| W2However, remember: hostvars only contains data if the target server facts have already been collected. Make sure your playbook includes all related hosts in the play targets, or explicitly run the fact gathering task for all hosts at the start of the playbook:
# templates/haproxy.cfg.j2
backend app_nodes
balance roundrobin
option httpchk GET /health
{% for host in groups['webservers'] -%}
server {{ host }} {{ hostvars[host]['ansible_default_ipv4']['address'] }}:{{ app_port | default(8080) }} check
{% endfor %}
Case Study: Dynamic HAProxy and Web Server Configuration #
Let’s combine all this material into a real case study. We’ll write a dynamic HAProxy configuration template (templates/haproxy.cfg.j2) that dynamically registers all web servers in our inventory group, filters out invalid external IPs, uses whitespace control to keep the file neat, and validates the HAProxy configuration before saving.
Here’s the content of our custom HAProxy template file (templates/haproxy.cfg.j2):
{# templates/haproxy.cfg.j2 #}
global
log /dev/log local0
log /dev/log local1 notice
chroot /var/lib/haproxy
user haproxy
group haproxy
daemon
maxconn {{ haproxy_max_connections | default(4096) | int }}
defaults
log global
mode http
option httplog
option dontlognull
timeout connect {{ haproxy_timeout_connect | default('5s') }}
timeout client {{ haproxy_timeout_client | default('50s') }}
timeout server {{ haproxy_timeout_server | default('50s') }}
frontend http_in
bind *:80
{% if haproxy_ssl_enabled | default('false') | bool -%}
bind *:443 ssl crt {{ haproxy_ssl_cert_path | mandatory }}
redirect scheme https code 301 if !{ ssl_fc }
{% endif -%}
default_backend web_servers
backend web_servers
balance {{ haproxy_balance_algorithm | default('roundrobin') }}
option forwardfor
http-request set-header X-Forwarded-Port %[dst_port]
http-request add-header X-Forwarded-Proto https if { ssl_fc }
option httpchk HEAD {{ haproxy_health_check_path | default('/health') }} HTTP/1.1\r\nHost:\ localhost
{# Loop using hostvars to identify backend IPs #}
{%- for host in groups['webservers'] %}
{%- set ip = hostvars[host]['ansible_default_ipv4']['address'] %}
{%- if ip | ipaddr %}
server {{ host }} {{ ip }}:{{ hostvars[host]['app_port'] | default(8080) }} check inter 2s fall 3 rise 2
{%- endif %}
{%- endfor %}
And this is the main playbook (playbooks/deploy-haproxy.yml) tasked with deploying that template to the load balancer server, complete with validation commands:
# playbooks/deploy-haproxy.yml
---
- name: Gather backend facts first
hosts: webservers
gather_facts: true # Mandatory to ensure backend hostvars are filled with IP facts
- name: Deploy the HAProxy Load Balancer
hosts: loadbalancers
become: true
vars:
haproxy_max_connections: 5000
haproxy_ssl_enabled: "true"
haproxy_ssl_cert_path: "/etc/ssl/certs/haproxy.pem"
haproxy_balance_algorithm: "leastconn"
haproxy_health_check_path: "/api/v1/health"
tasks:
- name: Ensure haproxy is installed
apt:
name: haproxy
state: present
update_cache: true
when: ansible_os_family == "Debian"
- name: Deploy a dummy certificate for SSL validation if enabled
copy:
content: "DUMMY CERTIFICATE"
dest: "{{ haproxy_ssl_cert_path }}"
mode: '0600'
force: false # Don't overwrite if the real certificate already exists
when: haproxy_ssl_enabled | bool
- name: Deploy the dynamic HAProxy configuration from the template
template:
src: templates/haproxy.cfg.j2
dest: /etc/haproxy/haproxy.cfg
owner: root
group: root
mode: '0644'
backup: true
# HAProxy's built-in syntax validation command
validate: 'haproxy -c -f %s'
notify: Restart HAProxy
handlers:
- name: Restart HAProxy
systemd:
name: haproxy
state: restarted
In this case study, the HAProxy template is configured very dynamically. If you add a new web server to the webservers group in your inventory, you just re-run this playbook. HAProxy reads the changed backend count, rebuilds the server list in the haproxy.cfg file, safely validates its syntax, and restarts the load balancer service without breaking active services if an IP writing error occurs.
Summary #
- Three Jinja2 Syntaxes — Use the
{{ }}delimiters to print variables,{% %}for logic blocks like if and for, and{# #}for internal comments.- Whitespace Control — Insert the minus character (
-) on Jinja2 statements ({%-and-%}) to remove unwanted empty lines in configuration output.- Bool Filter for Strings — Always use the
| boolfilter when evaluating string variable conditions to avoid data truthiness logic errors.- Default & Mandatory Filters — Set fallback values using the
| default(value)filter or force rendering failure if data is empty using the| mandatoryfilter.- Loop Last Variable — Use the internal
loop.lastvariable to detect the last loop element to avoid writing commas or separators that break JSON/YAML syntax.- IP Address Filter — Use the
| ipaddrfilter to validate entered IP address data before writing it to server configuration.- Cross-Host Access — Use the global
hostvarsvariable to dynamically build load balancer configurations based on fact data collected from backend servers.- Template Syntax Validation — Always include a
validateoption appropriate to the target tool (likenginx -t -c %sorhaproxy -c -f %s) to ensure production server stability.