Ansible

Configure Dynamic Config Files with Ansible Jinja2

Config files are where most Ansible complexity lives. A static copy module works until you need different ports per host, different firewall commands per OS, or dynamic service discovery entries. Jinja2 templates solve this by embedding variables, conditionals, and loops directly into configuration files that Ansible renders at deploy time.

Original content from computingforgeeks.com - post 166046

This guide covers the practical Jinja2 patterns you’ll use most: variable substitution, conditional blocks for cross-OS configs, loops for generating repetitive sections, filters for data transformation, and the validate parameter that catches config errors before they hit production. We test three real templates (Nginx, SSHD, and a firewall script) on Rocky Linux 10.1 and Ubuntu 24.04 with Ansible 13.5.0. If you haven’t covered Ansible variables yet, read that first because templates consume variables heavily.

Verified working: April 2026 on Rocky Linux 10.1 and Ubuntu 24.04 LTS, ansible-core 2.20.4, Jinja2 3.1.6

Prerequisites

Before starting:

Templates live in a templates/ directory alongside your playbook or inside a role’s structure. Ansible automatically searches this directory when you use the template module.

How Templates Work

The ansible.builtin.template module reads a Jinja2 file (ending in .j2 by convention), replaces all {{ variable }} placeholders with their values, evaluates {% if %} and {% for %} blocks, then writes the result to the remote host. Unlike the copy module which transfers files verbatim, template processes the content through the Jinja2 engine first.

Jinja2 uses three tag types:

TagPurposeExample
{{ }}Output a variable value{{ http_port }}
{% %}Logic (if, for, set){% if ssl_enabled %}
{# #}Comments (not rendered){# TODO: add rate limiting #}

Building an Nginx Config Template

Start with a practical example. This Nginx template uses variables for tuning, conditionals for optional SSL, and loops for dynamic proxy locations.

Create templates/nginx.conf.j2:

vi templates/nginx.conf.j2

Add the following template content:

# Managed by Ansible - Do not edit manually
# Generated for {{ inventory_hostname }} on {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}

worker_processes {{ nginx_worker_processes | default('auto') }};

events {
    worker_connections {{ nginx_worker_connections | default(1024) }};
}

http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;

    sendfile    on;
    tcp_nopush  on;
    keepalive_timeout {{ keepalive_timeout | default(65) }};

    server {
        listen {{ http_port }};
        server_name {{ server_name | default('_') }};
        root /usr/share/nginx/html;

{% if ssl_enabled | default(false) %}
        listen 443 ssl;
        ssl_certificate     {{ ssl_cert_path }};
        ssl_certificate_key {{ ssl_key_path }};
{% endif %}

{% for location in custom_locations | default([]) %}
        location {{ location.path }} {
            proxy_pass {{ location.backend }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }
{% endfor %}
    }
}

The default() filter on nearly every variable means the template works even if some values aren’t defined. SSL blocks only render when explicitly enabled. The for loop generates as many proxy locations as the variable list contains.

Deploying the Template

The playbook that renders and deploys this template:

---
- name: Deploy Nginx Config
  hosts: all
  become: true
  vars:
    nginx_worker_processes: auto
    nginx_worker_connections: 1024
    keepalive_timeout: 65
    server_name: app.example.com
    ssl_enabled: false
    custom_locations:
      - path: /api
        backend: "http://127.0.0.1:8080"
      - path: /metrics
        backend: "http://127.0.0.1:9090"

  tasks:
    - name: Deploy Nginx config from template
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        owner: root
        group: root
        mode: "0644"
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      ansible.builtin.systemd:
        name: nginx
        state: reloaded

Each host gets a config customized with its own http_port from host_vars. Rocky (port 8080) and Ubuntu (port 9090) receive different rendered configs from the same template:

# Rendered on rocky-managed:
    server {
        listen 8080;
        server_name app.example.com;

        location /api {
            proxy_pass http://127.0.0.1:8080;
        }
        location /metrics {
            proxy_pass http://127.0.0.1:9090;
        }
    }

# Rendered on ubuntu-managed:
    server {
        listen 9090;
        server_name app.example.com;
        ...

The handler only fires when the template actually changes the file. On subsequent runs where nothing changed, Ansible reports ok and skips the reload.

Template Validation

The validate parameter on the template module runs a command against the rendered file before it replaces the real config. If validation fails, the original file stays untouched. This prevents a bad template from breaking a running service.

For SSHD, the validation command is sshd -t -f %s where %s is replaced with the path to the rendered temporary file:

    - name: Render SSHD config with validation
      ansible.builtin.template:
        src: templates/sshd_config.j2
        dest: /etc/ssh/sshd_config
        mode: "0600"
        validate: "/usr/sbin/sshd -t -f %s"

Tested with our SSHD template, the validation passes and shows the key rendered values:

ok: [rocky-managed] => {
    "msg": [
        "Port 22",
        "PermitRootLogin prohibit-password",
        "PasswordAuthentication no",
        "AllowUsers root deploy ansible",
        "LogLevel VERBOSE"
    ]
}

Common validation commands by service:

ServiceValidate Command
SSHD/usr/sbin/sshd -t -f %s
Nginx/usr/sbin/nginx -t -c %s
Apache/usr/sbin/httpd -t -f %s
Sudoers/usr/sbin/visudo -cf %s
Chrony/usr/sbin/chronyd -p -f %s

Jinja2 Filters in Templates

Filters transform values inline using the pipe (|) syntax. Templates use many of the same filters available in playbooks, plus a few that are especially useful for config file generation.

Essential Filters for Templates

The filters you’ll reach for most often:

FilterWhat It DoesExample
default(value)Fallback if undefined{{ port | default(8080) }}
join(sep)Combine list into string{{ users | join(' ') }}
upper / lowerCase conversion{{ level | upper }}
trimRemove whitespace{{ input | trim }}
replace(old,new)String replacement{{ name | replace(' ','_') }}
truncate(n)Limit string length{{ desc | truncate(30) }}
to_yaml / to_jsonSerialize data{{ config | to_yaml }}
map(attribute)Extract from list of dicts{{ rules | map(attribute='port') }}
"%-16s" | format(val)Padded formattingAlign columns in generated files

Cross-OS Templates with Conditionals

One template generating OS-specific output is a powerful pattern. This firewall rules template produces firewall-cmd commands for RHEL and ufw commands for Debian, from the same source file:

vi templates/firewall_rules.j2

Add the template content:

# Firewall Rules for {{ inventory_hostname }}
# Generated: {{ ansible_facts['date_time']['date'] }}
# OS: {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}

{% for rule in firewall_rules %}
# {{ rule.description | default('No description') }}
{% if ansible_facts['os_family'] == 'RedHat' %}
firewall-cmd --permanent --add-port={{ rule.port }}/{{ rule.protocol | default('tcp') }}
{% elif ansible_facts['os_family'] == 'Debian' %}
ufw allow {{ rule.port }}/{{ rule.protocol | default('tcp') }} comment "{{ rule.description | default('') | truncate(30) }}"
{% endif %}
{% endfor %}

{% if ansible_facts['os_family'] == 'RedHat' %}
firewall-cmd --reload
{% endif %}

# Total rules: {{ firewall_rules | length }}
# Ports opened: {{ firewall_rules | map(attribute='port') | join(', ') }}

The playbook passes a list of firewall rules as variables:

  vars:
    firewall_rules:
      - { port: 22, protocol: tcp, description: "SSH access" }
      - { port: 80, protocol: tcp, description: "HTTP web traffic" }
      - { port: 443, protocol: tcp, description: "HTTPS encrypted web traffic" }
      - { port: 5432, protocol: tcp, description: "PostgreSQL database" }
      - { port: 9090, protocol: tcp, description: "Prometheus metrics" }

On Rocky Linux (RHEL family), the template renders firewall-cmd syntax:

# Firewall Rules for rocky-managed
# Generated: 2026-04-14
# OS: Rocky 10.1

# SSH access
firewall-cmd --permanent --add-port=22/tcp
# HTTP web traffic
firewall-cmd --permanent --add-port=80/tcp
# HTTPS encrypted web traffic
firewall-cmd --permanent --add-port=443/tcp
# PostgreSQL database
firewall-cmd --permanent --add-port=5432/tcp
# Prometheus metrics
firewall-cmd --permanent --add-port=9090/tcp

firewall-cmd --reload

# Total rules: 5
# Ports opened: 22, 80, 443, 5432, 9090

The same template on Ubuntu generates ufw syntax instead:

# Firewall Rules for ubuntu-managed
# Generated: 2026-04-14
# OS: Ubuntu 24.04

# SSH access
ufw allow 22/tcp comment "SSH access"
# HTTP web traffic
ufw allow 80/tcp comment "HTTP web traffic"
# HTTPS encrypted web traffic
ufw allow 443/tcp comment "HTTPS encrypted web traffic"
# PostgreSQL database
ufw allow 5432/tcp comment "PostgreSQL database"
# Prometheus metrics
ufw allow 9090/tcp comment "Prometheus metrics"

# Total rules: 5
# Ports opened: 22, 80, 443, 5432, 9090

Same data, same template, two completely different outputs. The map(attribute='port') filter at the bottom extracts just the port numbers from the list of dictionaries for a summary line.

Generating /etc/hosts from Inventory

Templates that reference groups and hostvars can generate files from your entire inventory. This hosts file template populates cluster entries dynamically:

# /etc/hosts - Managed by Ansible
# Last updated: {{ ansible_facts['date_time']['iso8601'] }}

127.0.0.1   localhost localhost.localdomain
::1         localhost localhost.localdomain

# Cluster hosts (auto-generated from inventory)
{% for host in groups['all'] %}
{{ hostvars[host]['ansible_host'] }}   {{ host }} {{ host }}.{{ domain | default('local') }}
{% endfor %}

{% if extra_hosts is defined %}
# Additional hosts
{% for entry in extra_hosts %}
{{ "%-16s" | format(entry.ip) }}{{ entry.hostname }}
{% endfor %}
{% endif %}

The rendered output on both hosts includes every machine from the inventory, plus any additional static entries:

# /etc/hosts - Managed by Ansible
# Last updated: 2026-04-14T08:41:59Z

127.0.0.1   localhost localhost.localdomain
::1         localhost localhost.localdomain

# Cluster hosts (auto-generated from inventory)
192.168.1.61   rocky-managed rocky-managed.example.com
192.168.1.62   ubuntu-managed ubuntu-managed.example.com

# Additional hosts
10.0.1.100      db01.example.com
10.0.1.101      cache01.example.com
10.0.1.102      lb01.example.com

The "%-16s" | format() pattern left-pads the IP addresses so the hostnames align in clean columns. Adding a new host to your inventory automatically adds it to every machine’s hosts file on the next playbook run.

Ansible Jinja2 template rendering Nginx config on Rocky Linux 10.1
Jinja2 template rendering a per-host Nginx config with dynamic proxy locations

Whitespace Control

Jinja2 blocks leave blank lines in the output where {% if %} and {% for %} tags are. Adding a dash inside the tag strips whitespace: {%- if -%} strips before and after, {%- if %} strips only before.

For config files where blank lines matter (like sudoers or systemd units), use the trim blocks and lstrip settings at the top of your template:

#jinja2: trim_blocks: True, lstrip_blocks: True
# Config file starts here
{% if feature_enabled %}
feature_setting = on
{% endif %}

With trim_blocks, the newline after a block tag is removed. With lstrip_blocks, leading whitespace before block tags is stripped. Together they produce clean output without manual dash placement on every tag.

Template Best Practices

After building templates across production environments, these patterns consistently prevent problems:

  • Always add a managed-by header with the hostname and generation timestamp. When someone SSHs in and sees a config file, they should know immediately that Ansible manages it and manual edits will be overwritten
  • Use default() on every optional variable. Templates that fail on missing variables are fragile. Set sensible defaults so the template renders even with minimal input
  • Use validate for every service config. A broken sshd_config can lock you out of a server. A broken nginx.conf kills the web server. Let Ansible check before deploying
  • Keep templates close to the playbook or role. The templates/ directory convention exists for a reason. Don’t scatter .j2 files across the project
  • Use ansible_facts dictionary syntax. The top-level fact injection (ansible_hostname) is deprecated in ansible-core 2.20 and will be removed in 2.24. Use ansible_facts['hostname'] instead
  • Combine with Ansible Vault for templates that contain secrets. Encrypt the vars file, not the template itself

Quick Reference

SyntaxPurposeExample
{{ var }}Variable output{{ http_port }}
{{ var | filter }}Filtered output{{ name | upper }}
{% if condition %}Conditional block{% if ssl_enabled %}
{% for x in list %}Loop{% for rule in rules %}
{# comment #}Template commentNot rendered in output
{%- tag -%}Whitespace controlStrips surrounding whitespace
validate: "cmd %s"Pre-deploy validationvalidate: "nginx -t -c %s"

For the complete learning path, see the Ansible Automation Guide. Templates combine naturally with Ansible roles, where each role packages its templates alongside tasks and defaults. The Ansible Cheat Sheet covers quick template syntax references.

Related Articles

Ansible How To Manage PostgreSQL Database with Ansible Ansible Generate OpenSSL Self-Signed Certificates with Ansible Automation Install Salt Master & Minion on Ubuntu 20.04|18.04 Automation Backup files to Scaleway Object Storage using AWS-CLI

Leave a Comment

Press ESC to close