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

ParameterDefaultDescription
src(required)Template file on controller (.j2)
dest(required)Destination path on remote
owner—File owner
group—File group
mode—File permissions
backupfalseBackup before overwrite
validate—Validation command
forcetrueOverwrite if different
newline_sequence\nLine ending style
trim_blockstrueRemove first newline after block tag
lstrip_blocksfalseStrip 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

ScenarioModule
Static files (no variables)copy
Dynamic config with variablestemplate
Binary filescopy
Files needing loops/conditionalstemplate
Inline contentcopy + 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:

  1. templates/ directory next to the playbook
  2. templates/ directory in the role
  3. Playbook directory

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.