Introduction

The ansible.builtin.template module processes Jinja2 templates and deploys the rendered output to remote hosts. It's how you generate dynamic configuration files — inject hostnames, IP addresses, port numbers, environment-specific values, and computed settings into any config format.

Basic Syntax

- name: Deploy config from template
  ansible.builtin.template:
    src: templates/app.conf.j2
    dest: /etc/myapp/app.conf
    owner: root
    group: root
    mode: "0644"

Parameters

ParameterDefaultDescription
src(required)Path to Jinja2 template (.j2)
dest(required)Remote destination path
owner—File owner
group—File group
mode—File permissions
backupfalseBackup existing file before overwriting
validate—Validation command (%s = temp file)
forcetrueOverwrite even if destination exists
newline_sequence\nLine ending: \n, \r, \r\n
trim_blockstrueRemove first newline after block tags
lstrip_blocksfalseStrip leading whitespace from block lines

Simple Template

Playbook:

---
- name: Deploy nginx config
  hosts: webservers
  become: true
  vars:
    http_port: 80
    server_name: "{{ ansible_fqdn }}"
    worker_processes: "{{ ansible_processor_vcpus }}"

  tasks:
    - name: Deploy nginx.conf
      ansible.builtin.template:
        src: nginx.conf.j2
        dest: /etc/nginx/nginx.conf
        validate: "nginx -t -c %s"
        backup: true
      notify: Reload nginx

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

templates/nginx.conf.j2:

# Managed by Ansible — do not edit manually
# Host: {{ inventory_hostname }}
# Generated: {{ ansible_date_time.iso8601 }}

worker_processes {{ worker_processes }};

events {
    worker_connections 1024;
}

http {
    server {
        listen {{ http_port }};
        server_name {{ server_name }};

        root /var/www/html;
        index index.html;

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

Jinja2 Variables

All Ansible variables are available in templates — facts, vars, registered results:

{# Variables from playbook vars #}
Database: {{ db_host }}:{{ db_port }}

{# Ansible facts #}
Server IP: {{ ansible_default_ipv4.address }}
OS: {{ ansible_distribution }} {{ ansible_distribution_version }}
RAM: {{ ansible_memtotal_mb }}MB

{# Default values #}
Log level: {{ log_level | default('info') }}
Workers: {{ worker_count | default(ansible_processor_vcpus) }}

{# Computed values #}
Cache size: {{ (ansible_memtotal_mb * 0.7) | int }}MB

Conditionals in Templates

{# if/elif/else #}
{% if env == 'production' %}
log_level = warn
ssl = true
{% elif env == 'staging' %}
log_level = info
ssl = true
{% else %}
log_level = debug
ssl = false
{% endif %}

{# Conditional line #}
{% if ssl_enabled %}
ssl_certificate /etc/ssl/certs/{{ ansible_fqdn }}.pem;
ssl_certificate_key /etc/ssl/private/{{ ansible_fqdn }}.key;
{% endif %}

{# Ternary for inline conditions #}
debug = {{ (env == 'production') | ternary('false', 'true') }}

Loops in Templates

{# Loop over a list #}
{% for server in upstream_servers %}
server {{ server }}:{{ backend_port | default(8080) }};
{% endfor %}

{# Loop with index #}
{% for user in admin_users %}
# User {{ loop.index }}: {{ user.name }}
{{ user.name }}:{{ user.role }}
{% endfor %}

{# Loop over dict #}
{% for key, value in app_settings.items() %}
{{ key }} = {{ value }}
{% endfor %}

{# Conditional in loop #}
{% for host in groups['webservers'] %}
{% if host != inventory_hostname %}
peer {{ hostvars[host]['ansible_default_ipv4']['address'] }};
{% endif %}
{% endfor %}

Filters

{# String filters #}
hostname: {{ ansible_hostname | upper }}
slug: {{ app_name | lower | replace(' ', '-') }}

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

{# List filters #}
servers: {{ server_list | join(', ') }}
first: {{ items | first }}
count: {{ items | length }}
sorted: {{ items | sort | join(', ') }}
unique: {{ tags | unique | join(', ') }}

{# Default values #}
port: {{ custom_port | default(8080) }}
log: {{ log_path | default('/var/log/app.log') }}

{# JSON/YAML output #}
config: {{ app_config | to_nice_json }}
env_vars: {{ env_dict | to_nice_yaml }}

{# IP address filters #}
network: {{ ansible_default_ipv4.address | ansible.utils.ipaddr('network') }}

Practical Examples

Application Config File

# playbook
vars:
  app_name: mywebapp
  db:
    host: db.internal
    port: 5432
    name: myapp_prod
    pool_size: 20
  redis:
    host: redis.internal
    port: 6379
  features:
    - caching
    - rate_limiting
    - websockets

templates/app.yml.j2:

# {{ app_name }} configuration
# Deployed by Ansible to {{ inventory_hostname }}

server:
  host: 0.0.0.0
  port: {{ http_port | default(8080) }}
  workers: {{ ansible_processor_vcpus }}

database:
  url: "postgresql://{{ db.host }}:{{ db.port }}/{{ db.name }}"
  pool_size: {{ db.pool_size }}

redis:
  url: "redis://{{ redis.host }}:{{ redis.port }}"

features:
{% for feature in features %}
  {{ feature }}: true
{% endfor %}

logging:
  level: {{ (env == 'production') | ternary('warn', 'debug') }}
  file: /var/log/{{ app_name }}/app.log

Hosts File

# /etc/hosts — managed by Ansible
127.0.0.1 localhost
{{ ansible_default_ipv4.address }} {{ ansible_fqdn }} {{ ansible_hostname }}

# Cluster nodes
{% for host in groups['all'] %}
{{ hostvars[host]['ansible_default_ipv4']['address'] }} {{ host }}
{% endfor %}

Sudoers File (with validate)

- name: Deploy sudoers
  ansible.builtin.template:
    src: sudoers.j2
    dest: /etc/sudoers.d/ansible-managed
    validate: "visudo -cf %s"
    owner: root
    mode: "0440"

templates/sudoers.j2:

# Managed by Ansible
{% for user in sudo_users %}
{{ user }} ALL=(ALL) NOPASSWD: ALL
{% endfor %}

{% for user in limited_sudo_users %}
{{ user }} ALL=(ALL) NOPASSWD: /usr/bin/systemctl restart {{ app_name }}
{% endfor %}

Template File Location

Templates are searched in this order:

project/
├── playbook.yml
├── templates/          ← template looks here
│   └── app.conf.j2
└── roles/
    └── myrole/
        └── templates/  ← template looks here for role tasks
            └── app.conf.j2

template vs copy

Featuretemplatecopy
Jinja2 processing✅❌ (file copied as-is)
Variable substitution✅❌ (only in content:)
Loops/conditionals✅❌
validate✅✅
Binary files❌✅
Inline content❌✅ (content:)

Rule of thumb: If the file needs any dynamic content → template. If it's static → copy.

Whitespace Control

{# Default — may leave blank lines #}
{% if debug %}
debug = true
{% endif %}

{# Trim whitespace with - #}
{%- if debug %}
debug = true
{%- endif %}

{# Or use trim_blocks and lstrip_blocks in the task #}
- ansible.builtin.template:
    src: config.j2
    dest: /etc/config
    trim_blocks: true
    lstrip_blocks: true  # Removes leading whitespace from {% %} lines

Troubleshooting

"UndefinedError: 'variable' is undefined"

Variable not set. Use default() filter:

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

Or make the variable mandatory with a clear error:

{{ my_var | mandatory }}

Template Output Has Extra Blank Lines

Enable lstrip_blocks and trim_blocks (see above), or use - in block tags:

{%- for item in list -%}
{{ item }}
{%- endfor -%}

Conclusion

The template module is essential for deploying dynamic configuration files. Write .j2 templates with Jinja2 variables, conditionals, loops, and filters — Ansible renders them with your playbook variables and facts, then deploys the result. Always use validate for critical configs (sudoers, nginx, sshd) to prevent deploying broken files. Use backup: true in production to keep the previous version. For static files without variables, use copy instead.