Introduction
ansible.builtin.template processes Jinja2 template files and deploys them to remote hosts. Unlike copy, templates support variables, loops, conditionals, and filters — making them the standard way to generate dynamic configuration files.
Basic Usage
- name: Deploy nginx config
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
Template file:
# templates/nginx.conf.j2
# Managed by Ansible — do not edit manually
server {
listen {{ http_port }};
server_name {{ server_name }};
root {{ document_root }};
index index.html;
access_log /var/log/nginx/{{ server_name }}.access.log;
error_log /var/log/nginx/{{ server_name }}.error.log;
}
Parameters
| Parameter | Default | Description |
|---|---|---|
src | (required) | Template file on controller (.j2) |
dest | (required) | Destination path on remote |
owner | — | File owner |
group | — | File group |
mode | — | File permissions |
backup | false | Backup before overwrite |
validate | — | Validation command |
force | true | Overwrite if different |
newline_sequence | \n | Line ending style |
trim_blocks | true | Remove first newline after block tag |
lstrip_blocks | false | Strip leading whitespace from block lines |
Variables in Templates
{# templates/app.conf.j2 #}
# Simple variables
app_name = {{ app_name }}
app_port = {{ app_port }}
environment = {{ env | default('production') }}
# Facts
hostname = {{ ansible_hostname }}
ip_address = {{ ansible_default_ipv4.address }}
memory_mb = {{ ansible_memtotal_mb }}
os = {{ ansible_distribution }} {{ ansible_distribution_version }}
# Dictionary access
db_host = {{ database.host }}
db_port = {{ database.port }}
db_name = {{ database.name }}
Conditionals
{# templates/nginx.conf.j2 #}
server {
listen {{ http_port }};
server_name {{ server_name }};
{% if enable_ssl | default(false) %}
listen 443 ssl;
ssl_certificate /etc/ssl/certs/{{ server_name }}.crt;
ssl_certificate_key /etc/ssl/private/{{ server_name }}.key;
{% endif %}
{% if enable_proxy %}
location / {
proxy_pass http://{{ backend_host }}:{{ backend_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
{% else %}
root {{ document_root }};
index index.html;
{% endif %}
}
Loops
{# templates/haproxy.cfg.j2 #}
frontend http
bind *:80
default_backend webservers
backend webservers
balance roundrobin
{% for host in groups['webservers'] %}
server {{ host }} {{ hostvars[host]['ansible_host'] }}:{{ app_port }} check
{% endfor %}
{# templates/hosts.j2 #}
# /etc/hosts managed by Ansible
127.0.0.1 localhost
{{ ansible_default_ipv4.address }} {{ ansible_hostname }}
{% for host in groups['all'] %}
{{ hostvars[host]['ansible_host'] }} {{ host }}
{% endfor %}
Filters
{# Common Jinja2 filters #}
uppercase = {{ app_name | upper }}
lowercase = {{ app_name | lower }}
default = {{ optional_var | default('fallback') }}
json = {{ config_dict | to_nice_json }}
yaml = {{ config_dict | to_nice_yaml }}
base64 = {{ secret | b64encode }}
join = {{ dns_servers | join(', ') }}
regex = {{ version | regex_replace('^v', '') }}
ternary = {{ debug_mode | ternary('DEBUG', 'INFO') }}
{# templates/resolv.conf.j2 #}
{% for ns in dns_servers %}
nameserver {{ ns }}
{% endfor %}
search {{ domain }}
options timeout:2 attempts:3
Practical Examples
Systemd Service File
{# templates/myapp.service.j2 #}
[Unit]
Description={{ app_name }} service
After=network.target
{% if requires_db %}
After=postgresql.service
Requires=postgresql.service
{% endif %}
[Service]
Type=simple
User={{ app_user }}
WorkingDirectory={{ app_dir }}
ExecStart={{ app_dir }}/bin/{{ app_name }} --port {{ app_port }}
Restart=always
RestartSec=5
{% for key, value in environment_vars.items() %}
Environment="{{ key }}={{ value }}"
{% endfor %}
[Install]
WantedBy=multi-user.target
SSH Config
{# templates/sshd_config.j2 #}
Port {{ ssh_port | default(22) }}
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 allowed_users is defined %}
AllowUsers {{ allowed_users | join(' ') }}
{% endif %}
Database Config
{# templates/postgresql.conf.j2 #}
listen_addresses = '{{ pg_listen | default("localhost") }}'
port = {{ pg_port | default(5432) }}
max_connections = {{ pg_max_connections | default(100) }}
# Memory (auto-calculated from facts)
shared_buffers = {{ (ansible_memtotal_mb * 0.25) | int }}MB
effective_cache_size = {{ (ansible_memtotal_mb * 0.75) | int }}MB
work_mem = {{ (ansible_memtotal_mb * 0.025) | int }}MB
# Logging
log_destination = 'stderr'
logging_collector = on
log_min_duration_statement = {{ pg_slow_query_ms | default(1000) }}
Environment File
{# templates/.env.j2 #}
# Generated by Ansible on {{ ansible_date_time.iso8601 }}
# Host: {{ inventory_hostname }}
NODE_ENV={{ node_env | default('production') }}
PORT={{ app_port }}
DATABASE_URL=postgresql://{{ db_user }}:{{ db_password }}@{{ db_host }}:{{ db_port }}/{{ db_name }}
REDIS_URL=redis://{{ redis_host }}:{{ redis_port }}
SECRET_KEY={{ secret_key }}
LOG_LEVEL={{ log_level | default('info') }}
Template with Validation
# Validate config before deploying
- name: Deploy sudoers
ansible.builtin.template:
src: sudoers.j2
dest: /etc/sudoers
validate: "visudo -cf %s"
mode: "0440"
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: "nginx -t -c %s"
notify: Reload nginx
- name: Deploy Apache config
ansible.builtin.template:
src: httpd.conf.j2
dest: /etc/httpd/conf/httpd.conf
validate: "apachectl -t -f %s"
notify: Reload apache
Advanced Patterns
Include Other Templates
{# templates/nginx.conf.j2 #}
http {
{% for vhost in virtual_hosts %}
{% include 'vhost.conf.j2' %}
{% endfor %}
}
Macros (Reusable Blocks)
{% macro upstream(name, servers, port) %}
upstream {{ name }} {
{% for server in servers %}
server {{ server }}:{{ port }};
{% endfor %}
}
{% endmacro %}
{{ upstream('web', groups['webservers'] | map('extract', hostvars, 'ansible_host') | list, 8080) }}
{{ upstream('api', groups['apiservers'] | map('extract', hostvars, 'ansible_host') | list, 3000) }}
template vs copy
| Scenario | Module |
|---|---|
| Static files (no variables) | copy |
| Dynamic config with variables | template |
| Binary files | copy |
| Files needing loops/conditionals | template |
| Inline content | copy + content |
Troubleshooting
Undefined Variable
{# Use default filter to prevent errors #}
port = {{ custom_port | default(8080) }}
{# Or check if defined #}
{% if custom_port is defined %}
port = {{ custom_port }}
{% endif %}
Whitespace Control
{# Strip whitespace with minus sign #}
{% for item in list -%}
{{ item }}
{%- endfor %}
Template Not Found
Templates are searched in:
templates/directory next to the playbooktemplates/directory in the role- Playbook directory
Related Articles
Conclusion
The template module is essential for deploying configuration files. Use Jinja2 variables for per-host customization, conditionals for optional sections, loops for dynamic lists, filters for data transformation, and macros for reusable blocks. Always use validate for critical config files (sudoers, nginx, sshd) and notify handlers to restart services when configs change. Store templates in templates/ with .j2 extension.