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
| Parameter | Default | Description |
|---|---|---|
src | (required) | Path to Jinja2 template (.j2) |
dest | (required) | Remote destination path |
owner | — | File owner |
group | — | File group |
mode | — | File permissions |
backup | false | Backup existing file before overwriting |
validate | — | Validation command (%s = temp file) |
force | true | Overwrite even if destination exists |
newline_sequence | \n | Line ending: \n, \r, \r\n |
trim_blocks | true | Remove first newline after block tags |
lstrip_blocks | false | Strip 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
| Feature | template | copy |
|---|---|---|
| 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 -%}
Related Articles
- Ansible copy Module
- Ansible Jinja2 Filters
- Ansible ternary Filter
- Ansible Variables Guide
- Ansible lineinfile Module
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.