Introduction

The ansible.builtin.template module processes Jinja2 templates (.j2 files) and deploys the rendered result to remote hosts. Variables, loops, conditionals, and filters let you generate dynamic configuration files from a single template.

Basic Usage

- name: Deploy nginx config
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    group: root
    mode: "0644"
    validate: "nginx -t -c %s"
  notify: Reload nginx

Template Syntax

Variables

{# templates/app.conf.j2 #}
server_name={{ app_domain }}
listen_port={{ app_port }}
environment={{ app_env }}
workers={{ ansible_processor_vcpus * 2 }}
hostname={{ ansible_hostname }}

Conditionals

{# templates/nginx.conf.j2 #}
server {
    listen 80;
    server_name {{ app_domain }};

{% if ssl_enabled %}
    listen 443 ssl;
    ssl_certificate /etc/ssl/{{ app_domain }}.crt;
    ssl_certificate_key /etc/ssl/{{ app_domain }}.key;
    
    # Redirect HTTP to HTTPS
    if ($scheme = http) {
        return 301 https://$server_name$request_uri;
    }
{% endif %}

    location / {
        proxy_pass http://127.0.0.1:{{ app_port }};
{% if proxy_websocket | default(false) %}
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
{% endif %}
    }
}

Loops

{# templates/hosts.j2 #}
# Managed by Ansible
127.0.0.1 localhost

{% for host in app_hosts %}
{{ host.ip }}    {{ host.name }}.{{ domain }}    {{ host.name }}
{% endfor %}

{# templates/upstream.conf.j2 #}
upstream {{ app_name }} {
{% for server in app_servers %}
    server {{ server.host }}:{{ server.port }}{% if server.weight is defined %} weight={{ server.weight }}{% endif %};
{% endfor %}
}

Filters

{# String filters #}
hostname={{ inventory_hostname | upper }}
short_name={{ inventory_hostname | regex_replace('\..*$', '') }}
encoded={{ password | b64encode }}

{# Number filters #}
cache_mb={{ (ansible_memtotal_mb * 0.25) | int }}
disk_gb={{ (disk_bytes / 1073741824) | round(1) }}

{# List filters #}
servers={{ backend_servers | join(', ') }}
first_dns={{ dns_servers | first }}
total={{ items | length }}

{# Default values #}
port={{ custom_port | default(8080) }}
log_level={{ log_level | default('info') }}

Parameters

ParameterDefaultDescription
src(required)Template file path (.j2)
dest(required)Destination path on remote
owner—File owner
group—File group
mode—File permissions
validate—Validation command (%s = temp file)
backupfalseCreate backup before overwriting
forcetrueOverwrite if content differs
newline_sequence\nLine ending style
trim_blockstrueRemove first newline after block tag
lstrip_blocksfalseStrip leading whitespace from block lines

Practical Templates

Nginx Virtual Host

{# templates/vhost.conf.j2 #}
server {
    listen {{ http_port | default(80) }};
    server_name {{ server_names | join(' ') }};
    root {{ document_root }};
    index index.html;

    access_log /var/log/nginx/{{ app_name }}-access.log;
    error_log /var/log/nginx/{{ app_name }}-error.log;

{% for location in locations | default([]) %}
    location {{ location.path }} {
{% if location.proxy_pass is defined %}
        proxy_pass {{ location.proxy_pass }};
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
{% elif location.root is defined %}
        root {{ location.root }};
{% endif %}
{% if location.auth | default(false) %}
        auth_basic "Restricted";
        auth_basic_user_file /etc/nginx/.htpasswd;
{% endif %}
    }

{% endfor %}
}

systemd Service Unit

{# templates/app.service.j2 #}
[Unit]
Description={{ app_name | capitalize }} Application
After=network.target{% if requires_db | default(false) %} postgresql.service{% endif %}

{% if requires_db | default(false) %}
Requires=postgresql.service
{% endif %}

[Service]
Type={{ service_type | default('simple') }}
User={{ app_user }}
Group={{ app_group | default(app_user) }}
WorkingDirectory={{ app_dir }}
ExecStart={{ app_dir }}/bin/{{ app_name }} --config {{ app_dir }}/config.yml
Restart=always
RestartSec={{ restart_sec | default(5) }}

{% for key, value in env_vars | default({}) | dictsort %}
Environment={{ key }}={{ value }}
{% endfor %}

{% if memory_limit is defined %}
MemoryLimit={{ memory_limit }}
{% endif %}
{% if cpu_quota is defined %}
CPUQuota={{ cpu_quota }}
{% endif %}

[Install]
WantedBy=multi-user.target

Application Config (YAML)

{# templates/config.yml.j2 #}
# {{ app_name }} configuration
# Managed by Ansible - DO NOT EDIT MANUALLY

server:
  host: {{ bind_address | default('0.0.0.0') }}
  port: {{ app_port }}
  workers: {{ workers | default(ansible_processor_vcpus) }}

database:
  host: {{ db_host }}
  port: {{ db_port | default(5432) }}
  name: {{ db_name }}
  username: {{ db_user }}
  password: {{ db_password }}
  pool_size: {{ db_pool | default(10) }}

{% if redis_host is defined %}
cache:
  host: {{ redis_host }}
  port: {{ redis_port | default(6379) }}
  ttl: {{ cache_ttl | default(3600) }}
{% endif %}

logging:
  level: {{ log_level | default('info') }}
  format: json
{% if log_file is defined %}
  file: {{ log_file }}
{% endif %}

{% if features is defined %}
features:
{% for feature in features %}
  {{ feature.name }}: {{ feature.enabled | lower }}
{% endfor %}
{% endif %}

SSH Config

{# templates/sshd_config.j2 #}
# Managed by Ansible
Port {{ ssh_port | default(22) }}
ListenAddress {{ ssh_listen | default('0.0.0.0') }}

PermitRootLogin {{ 'yes' if allow_root_login | default(false) else 'no' }}
PasswordAuthentication {{ 'yes' if allow_password_auth | default(false) else 'no' }}
PubkeyAuthentication yes
MaxAuthTries {{ max_auth_tries | default(3) }}

{% if ssh_allowed_users is defined %}
AllowUsers {{ ssh_allowed_users | join(' ') }}
{% endif %}

{% if ssh_allowed_groups is defined %}
AllowGroups {{ ssh_allowed_groups | join(' ') }}
{% endif %}

{% for match in ssh_match_blocks | default([]) %}
Match {{ match.type }} {{ match.value }}
{% for key, val in match.options | dictsort %}
    {{ key }} {{ val }}
{% endfor %}

{% endfor %}

Advanced Jinja2

Whitespace Control

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

{# Or use lstrip_blocks in template module #}

Macros (Reusable Blocks)

{% macro upstream(name, servers, port=8080) %}
upstream {{ name }} {
{% for server in servers %}
    server {{ server }}:{{ port }};
{% endfor %}
}
{% endmacro %}

{{ upstream('app', app_servers, app_port) }}
{{ upstream('api', api_servers, api_port) }}

Include Other Templates

{# Include a partial template #}
{% include 'ssl-params.j2' %}

{# Include if exists #}
{% include 'custom-overrides.j2' ignore missing %}

Ternary Expressions

log_level={{ 'debug' if debug_mode | default(false) else 'info' }}
protocol={{ 'https' if ssl_enabled else 'http' }}
bind={{ '127.0.0.1' if env == 'development' else '0.0.0.0' }}

Template with Validation

# Validate before deploying
- template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    validate: "nginx -t -c %s"

- template:
    src: sshd_config.j2
    dest: /etc/ssh/sshd_config
    validate: "sshd -t -f %s"

- template:
    src: sudoers.j2
    dest: /etc/sudoers
    validate: "visudo -cf %s"

- template:
    src: apache.conf.j2
    dest: /etc/httpd/conf/httpd.conf
    validate: "apachectl -t -f %s"

Troubleshooting

"UndefinedError: 'x' is undefined"

Variable not defined. Use default() filter:

{{ my_var | default('fallback_value') }}

{% if optional_var is defined %}
setting={{ optional_var }}
{% endif %}

Template Renders Literal {{ }}

Make sure the file has .j2 extension and you're using template, not copy:

# WRONG — copy doesn't process Jinja2
- copy: { src: config.j2, dest: /etc/config }

# RIGHT
- template: { src: config.j2, dest: /etc/config }

YAML in Templates

When generating YAML, watch indentation:

# Use indent filter for nested structures
env_vars:
{{ env_dict | to_nice_yaml | indent(2, first=true) }}

Conclusion

Jinja2 templates turn static config files into dynamic, reusable assets. Use variables for values, conditionals for optional sections, loops for repeated blocks, and filters for transformation. Always validate critical configs (validate parameter) before deploying. Keep templates in templates/ directory with .j2 extension. The template module processes Jinja2; copy doesn't — choose the right one.