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
| Parameter | Default | Description |
|---|---|---|
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) |
backup | false | Create backup before overwriting |
force | true | Overwrite if content differs |
newline_sequence | \n | Line ending style |
trim_blocks | true | Remove first newline after block tag |
lstrip_blocks | false | Strip 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) }}
Related Articles
- Ansible copy Module
- Ansible Variables Guide
- Ansible Vault for Secrets
- Ansible lineinfile Module
- Ansible blockinfile Module
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.