The ansible.builtin.template module renders Jinja2 .j2 files on the control node and writes the result to managed hosts. Use it when file content depends on variables, facts, inventory, or environment—not when a static file is enough.
This guide covers template syntax, variables, facts, {% if %} / {% for %}, common filters, whitespace control, validate and backup, and notifying handlers when rendered output changes. It assumes a basic playbook from your first playbook tutorial. For variable precedence, see variables; for playbook when, see conditionals—here we focus on Jinja2 inside .j2 files only.
Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16.
~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
| Need | Module |
|---|---|
| Static file, no rendering | copy |
| Dynamic file from Jinja2 | template |
| One-line edit in place | lineinfile |
| Managed multi-line block | blockinfile |
template task: put # {{ ansible_managed }} (or {{ ansible_managed | comment }}) at the top of .j2 sources so operators know the file is generated—never embed render timestamps unless you want changed every run; use {%- and -%} whitespace control where blank lines would break INI, nginx, or systemd syntax; set validate with a checker that matches the exact file you deploy (visudo -cf %s, nginx -t -c %s, sshd -t -f %s) so a bad render never replaces a working config.
What is the Ansible template Module?
template reads a Jinja2 source file from the control node (typically templates/*.j2), renders it with variables, facts, and inventory context, then writes the rendered text to dest on the target host.
- Rendering happens on the control node before transfer.
- Filters such as
defaultandjoinrun during that render step (Ansible filters). - If rendered content matches the file already on the host, the task reports
ok; if not,changed.
Official reference: template – Template a file to a remote server.
Why Use template Instead of copy?
| Situation | Use |
|---|---|
| Same bytes on every host | copy |
| Hostname, port, or env in the file | template |
Value from ansible_facts |
template |
| List of backends from variables | template |
| Static tarball or binary | copy (not template) |
copy does not process {{ }} or {% %}—those appear literally in the destination if you use copy by mistake.
How Ansible Template Rendering Works
- Ansible loads the
.j2source fromtemplates/(or a role’stemplates/). - Jinja2 evaluates expressions using play variables, facts, and magic variables (
inventory_hostname,groups,hostvars, …). - The rendered string is compared to the current
destfile. - If different, Ansible writes the new content (optionally after
validateand withbackup).
Basic Syntax of ansible.builtin.template
Common parameters:
| Parameter | Purpose |
|---|---|
src |
Template file name under templates/ |
dest |
Path on the managed host |
owner / group / mode |
File permissions |
backup |
Keep a timestamped backup when content changes |
validate |
Run a command against the temp file before replacing dest (%s = temp path) |
- name: Deploy application config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/demoapp/app.conf
owner: root
group: root
mode: "0644"
backup: trueCreate Your First Jinja2 Template File
Create templates/app.conf.j2:
# {{ ansible_managed }}
app_name={{ app_name }}
app_port={{ app_port }}
app_env={{ app_env | default('development') }}Use ansible_managed when you want a consistent generated-file header controlled from ansible.cfg (template variables). Avoid adding render timestamps to headers unless you intentionally want the file to change every run.
Playbook:
---
- name: Basic template demo
hosts: lab
gather_facts: false
vars:
app_name: demoapp
app_port: 8080
tasks:
- name: Render simple app config
ansible.builtin.template:
src: app.conf.j2
dest: /tmp/template-demo-app.conf
mode: "0644"ansible-playbook basic-template.ymlSample output:
TASK [Render simple app config] ************************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=1 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Rendered file:
Sample output:
# Ansible managed
app_name=demoapp
app_port=8080
app_env=developmentA second run with the same variables reports ok and changed=0—the template is idempotent when content is stable.
Use Variables in Jinja2 Templates
Use simple variables
Print with {{ variable_name }}. No {{ }} inside {% %} control tags.
Ansible resolves names from play vars, inventory host_vars / group_vars, role defaults, registered results, gathered facts, and magic variables such as inventory_hostname. Precedence rules live in variables—templates see the same resolved values as tasks.
Use dictionary and list variables
templates/app-rich.conf.j2:
# {{ inventory_hostname }} application config
owner={{ app_owner.name }}
email={{ app_owner.email | default('[email protected]') }}
[features]
{% for feature in app_features %}
- {{ feature | upper }}
{% endfor %}ansible-playbook rich-template.ymlSample output:
# localhost application config
owner=Deepak
[email protected]
[features]
- METRICS
- CACHEDictionary keys use dot or bracket notation: app_owner.email or app_owner['email']. For deeper variable patterns, see variables—only template usage is shown here.
Use default values for missing variables
{{ app_env | default('development') }} applies when app_env is undefined.
By default, default() does not replace empty strings, false, 0, or None. Pass true as the second argument when those should also fall back:
app_env={{ app_env | default('development', true) }}Use default(..., true) carefully for booleans—false may be a meaningful value you must keep.
Use Ansible Facts in Templates
Gather facts when the template needs runtime system data.
Use ansible_facts in template files
templates/facts-snippet.j2:
hostname={{ ansible_facts.hostname }}
os={{ ansible_facts['os_family'] }}
distribution={{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}
primary_ip={{ ansible_facts['default_ipv4']['address'] | default('n/a') }}- name: Facts in template
hosts: lab
gather_facts: true
tasks:
- name: Render facts snippet
ansible.builtin.template:
src: facts-snippet.j2
dest: /tmp/template-demo-facts.conf
mode: "0644"Sample output:
hostname=rocky1
os=RedHat
distribution=Rocky 10.2
primary_ip=10.0.2.15More fact keys: custom facts—use only what the template needs.
Use inventory_hostname and magic variables
Common in templates without full fact gathering:
{{ inventory_hostname }}— name from inventory{{ groups['web'] }}— hosts in a group{{ hostvars['web1']['ansible_host'] }}— another host’s variable
Use these for /etc/hosts snippets, cluster config, and per-host naming in otherwise shared templates.
When a template reads hostvars[host]['ansible_facts'] for other hosts, those facts must already be gathered (or cached) on those hosts—otherwise keys are missing. The hosts.local.j2 example runs with gather_facts: true on the play so default_ipv4 is available.
Use Jinja2 Conditionals in Templates
Use {% if %}, {% elif %}, {% else %}, {% endif %} inside .j2 files—not playbook when syntax.
templates/conditional.conf.j2:
{% if app_env == 'production' %}
log_level=error
debug=false
{% elif app_env == 'staging' %}
log_level=warn
debug=false
{% else %}
log_level=info
debug=true
{% endif %}With app_env: production, rendered output:
log_level=error
debug=falseFor playbook-level conditions, see the conditionals guide linked above—not duplicated here.
Use Jinja2 Loops in Templates
{% for item in list %} ... {% endfor %} iterates over variables or inventory-derived lists.
templates/backends.conf.j2:
backends={{ backend_servers | join(',') }}
{% for server in backend_servers %}
server {{ server }}
{% endfor %}Sample output:
backends=web1.example.com,web2.example.com
server web1.example.com
server web2.example.comLoop over groups['web'] or a variable list of backends when building load-balancer or app config. For Ansible task loops (loop: on tasks), see loops—only Jinja2 for inside templates here.
Use Jinja2 Filters in Templates
Filters transform values with pipe syntax: {{ variable | filter }}. Ansible applies them on the control node during render.
Format strings and values
| Filter | Example |
|---|---|
upper / lower |
{{ feature | upper }} |
replace |
{{ name | replace(' ', '-') }} |
default |
{{ port | default(8080) }} |
Join lists and render multi-line values
{{ backend_servers | join(',') }} builds a comma-separated line. Use join(' ') for space-separated service lists.
Convert data to YAML or JSON
Embed structured data in config:
# {{ settings | to_nice_yaml(indent=2) }}to_yaml, to_nice_yaml, to_json, and indent help when a file needs a YAML or JSON block generated from a variable dict—keep logic minimal; huge blobs belong in variables, not the template.
| Filter | Typical use in templates |
|---|---|
mandatory |
Fail render if a required variable is undefined: {{ db_password | mandatory }} |
quote |
Shell-safe quoted string |
basename / dirname |
Split a path into filename or directory |
dict2items |
Loop over a dict as key/value pairs in {% for %} |
to_nice_json |
Readable JSON block for app config |
to_nice_yaml |
Readable YAML fragment inside a larger file |
Manage Whitespace and Newlines in Jinja2
Ansible loads templates with trim_blocks=True by default, so behavior may differ from plain Jinja2 outside Ansible. You can also set template-level options with a #jinja2: header (template designer docs).
When a template still produces unwanted blank lines or spaces, use whitespace control:
{%-strips whitespace before the tag-%}strips whitespace after the tag
Use {%- and -%} only where the generated file format requires tight output—not on every tag by default.
templates/hosts.local.j2:
{% for host in groups['lab'] -%}
{{ hostvars[host]['ansible_facts']['default_ipv4']['address'] | default('127.0.0.1') }} {{ host }}
{% endfor %}Rendered:
Sample output:
10.0.2.15 localhostWithout -, extra blank lines can appear between entries. Trim loops that must not add spurious newlines in production configs.
Escape Literal Jinja2 Braces
Sometimes the destination file must contain literal {{ }} or {% %} because another tool reads them later (Docker, Prometheus, Helm, Go templates). Do not let Ansible render those accidentally.
Small literal expression:
docker_format={{ '{{' }} .NetworkSettings.IPAddress {{ '}}' }}Larger literal block:
{% raw %}
{{ value_that_should_not_be_rendered_by_ansible }}
{% endraw %}For templates with many conflicting delimiters, use a #jinja2: header in the template source to change Jinja2 markers instead of escaping every line (template designer documentation).
When another tool will template the same file later (Helm, Go templates, app-side rendering), keep Ansible’s Jinja2 to the values it must inject—avoid double-templating large blocks that the downstream engine should own.
Validate Template Output Before Replacing Files
validate runs a command against the temporary rendered file Ansible passes as %s before replacing dest. Shell features such as pipes or expansion do not work directly in the validate command string (template module).
Prefer validators that match the exact file shape you deploy:
| Deployed file | Example validate |
|---|---|
| sudoers fragment | visudo -cf %s |
Full sshd config |
sshd -t -f %s |
Full nginx.conf |
nginx -t -c %s |
sshd -t -f %s tests %s as a standalone config file—not as a drop-in automatically included from the live /etc/ssh/sshd_config. Drop-in snippets under sshd_config.d/ may need a different validation workflow (test merged config on the host, or template the full main file when that matches your process).
Sudoers example:
- name: Deploy sudoers drop-in with validation
ansible.builtin.template:
src: sudoers-fragment.j2
dest: /etc/sudoers.d/app-deploy
mode: "0440"
owner: root
group: root
validate: visudo -cf %sFull web server main config:
- name: Deploy nginx main config after validation
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: "0644"
validate: nginx -t -c %s
notify: reload nginxIf validation fails, the task fails and the previous dest file stays in place.
Preview changes before applying them on production hosts:
ansible-playbook basic-template.yml --check --diffSample output:
TASK [Render simple app config] ************************************************
--- before
+++ after: /root/.ansible/tmp/.../app.conf.j2
@@ -0,0 +1,4 @@
+# Ansible managed
+app_name=demoapp
+app_port=8080
+app_env=development
changed: [localhost]--check predicts whether the task would change the file; --diff shows the rendered difference when supported. Use this before changing sshd, sudoers, nginx, systemd, or application config files.
Use backup with template Module
backup: true saves a timestamped copy of the previous dest on the managed host when content changes—useful under /etc before replacing production config.
A backup file is not a full rollback plan by itself. You still need a documented restore procedure if a bad template slips through.
- name: Deploy sudoers fragment with backup
ansible.builtin.template:
src: sudoers-fragment.j2
dest: /etc/sudoers.d/app-deploy
mode: "0440"
backup: true
validate: visudo -cf %sNotify Handlers When Templates Change
Handlers run only when the template task reports changed—not on every play.
- name: Deploy env file
ansible.builtin.template:
src: app.service.env.j2
dest: /tmp/template-demo-app.service.env
mode: "0644"
notify: reload app config
handlers:
- name: reload app config
ansible.builtin.service:
name: demoapp
state: reloadedFirst run queues the handler when rendered content differs; second run with the same data skips it. Flush timing and listen live in handlers—only the template connection is shown here.
changed every run, run with --diff and inspect the rendered output. Common causes are timestamps such as ansible_date_time, random values, unordered data, or whitespace that renders differently each time. Ansible docs note that date strings in templates can mark the task changed on every run.
template vs copy vs lineinfile vs blockinfile
| Requirement | Best module | Why |
|---|---|---|
| Deploy a static config file | copy |
Content does not change per host |
| Generate config using variables | template |
Renders Jinja2 before copying |
| Generate config using facts | template |
Can use ansible_facts and magic variables |
| Replace one setting in existing file | lineinfile |
Better for one-line edits |
| Insert a managed section | blockinfile |
Better for controlled multi-line blocks |
| Restart service after config change | template + handler |
Handler runs only when rendered file changes |
| Validate config before replacing | template with validate |
Prevents broken config deployment |
Brief comparison with copy, fetch, and file modules and when to use lineinfile, blockinfile, replace, or template—no full file-management or module-selection tutorial here.
Common Template Examples
Generate an application config file
See app.conf.j2 and basic-template.yml above—good for app settings that differ per environment using play or inventory variables.
Generate an nginx or httpd virtual host file
<VirtualHost *:{{ http_port | default(80) }}>
ServerName {{ inventory_hostname }}
DocumentRoot {{ doc_root }}
</VirtualHost>Use when each host needs its own vhost name or path; notify a handler to reload the web server only when the rendered vhost changes.
Generate /etc/hosts from inventory
Use groups and hostvars with a for loop (see hosts.local.j2) to build cluster-aware host tables—the generate hosts and archives guide walks through templating /etc/hosts and pulling reports back to the controller; review carefully before overwriting a full /etc/hosts.
Generate a systemd environment file
templates/app.service.env.j2:
[Service]
Environment="APP_ENV={{ app_env }}"
Environment="BACKENDS={{ backend_servers | join(' ') }}"Use for systemd drop-in environment fragments where service variables come from Ansible data.
Generate config from a list of backend servers
See backends.conf.j2—join for one line, for for multi-line server stanzas in load-balancer or app routing config.
Template a file and restart a service only when it changes
Pair template with notify on a handler (see handler example). Run the play twice; the second run should show ok on the template task and skip the handler when content is unchanged.
Common Mistakes
| Mistake | Why it is a problem |
|---|---|
Using copy for dynamic config files |
copy does not render Jinja2 templates like template does |
Forgetting .j2 template syntax |
Variables need {{ }} and control blocks need {% %} |
Using undefined variables without default |
The playbook may fail or generate incomplete config |
| Mixing YAML indentation and Jinja2 indentation incorrectly | Can generate invalid config files |
| Ignoring whitespace control | Extra blank lines or spaces can break YAML, INI, nginx, or systemd files |
Using ansible_date_time in templates blindly |
Can make the template task report changed every run |
Not using validate for critical config |
A bad template can break sshd, sudoers, nginx, or app startup |
Forgetting notify handlers |
Config changes may not reload the related service |
| Hardcoding host-specific values | Better to use inventory variables, group_vars, host_vars, or facts |
| Making templates too complex | Too much logic inside templates becomes hard to maintain |
Expecting default() to replace empty or false values |
Second argument defaults to false; use default('x', true) only when intended |
Not escaping literal {{ }} for other tools |
Ansible renders braces unless you use {% raw %} or delimiter overrides |
Validating sshd drop-ins with sshd -t -f %s alone |
Temp file is not tested in the same include context as the live main config |
Best Practices
| Best practice | Why |
|---|---|
Use ansible.builtin.template |
Clear and documentation-friendly |
Keep templates in a templates/ directory |
Matches common Ansible project layout |
Use .j2 extension |
Makes template sources easy to identify |
| Keep complex logic out of templates | Easier to test and maintain |
| Use vars, group_vars, and host_vars for data | Keeps template files clean |
Use default filter for optional values |
Prevents undefined variable errors |
Use validate for critical config |
Avoids deploying broken files |
| Use handlers for service reload/restart | Restarts only when template output changes |
| Control whitespace carefully | Avoids invalid generated config |
Set owner, group, and mode explicitly |
Safer for production config files |
Preview with --check --diff before production deploy |
Shows whether the template would change and the rendered diff |
Summary
The template module renders Jinja2 on the control node and deploys dynamic config per host. Use copy for static files; use template when variables, facts, or inventory shape the content. Inside .j2 files, combine {{ }}, {% if %}, {% for %}, and filters such as default, join, and to_nice_yaml. Control whitespace with {%- and -%}, use validate and backup on critical paths, and notify handlers so services reload only when rendered output actually changes.
References
- template module — parameters, validate, backup
- Template designer documentation — Jinja2 in Ansible
- Using filters — filter plugins and pipe syntax
- Ansible handlers — notify when template changes
- Ansible variables — data for templates
- copy and file modules — static file deployment

