Ansible Template Module and Jinja2 Explained with Examples

Learn the Ansible template module and Jinja2—variables, facts, conditionals, loops, filters, whitespace, validate, backup, handlers, and template vs copy with tested examples.

Published

Updated

Read time 14 min read

Reviewed byDeepak Prasad

Ansible template module and Jinja2 templates on Rocky Linux 10

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.

NOTE
This chapter is part of the GoLinuxCloud Ansible tutorial (RHCE EX294). Follow along from ~/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
IMPORTANT
Three production habits on every 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 default and join run 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

  1. Ansible loads the .j2 source from templates/ (or a role’s templates/).
  2. Jinja2 evaluates expressions using play variables, facts, and magic variables (inventory_hostname, groups, hostvars, …).
  3. The rendered string is compared to the current dest file.
  4. If different, Ansible writes the new content (optionally after validate and with backup).

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)
yaml
- name: Deploy application config
  ansible.builtin.template:
    src: app.conf.j2
    dest: /etc/demoapp/app.conf
    owner: root
    group: root
    mode: "0644"
    backup: true

Create Your First Jinja2 Template File

Create templates/app.conf.j2:

jinja2
# {{ 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:

yaml
---
- 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"
bash
ansible-playbook basic-template.yml

Sample output:

output
TASK [Render simple app config] ************************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Rendered file:

Sample output:

output
# Ansible managed
app_name=demoapp
app_port=8080
app_env=development

A 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:

jinja2
# {{ inventory_hostname }} application config
owner={{ app_owner.name }}
email={{ app_owner.email | default('[email protected]') }}

[features]
{% for feature in app_features %}
- {{ feature | upper }}
{% endfor %}
bash
ansible-playbook rich-template.yml

Sample output:

output
# localhost application config
owner=Deepak
[email protected]

[features]
- METRICS
- CACHE

Dictionary 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:

jinja2
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:

jinja2
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') }}
yaml
- 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:

output
hostname=rocky1
os=RedHat
distribution=Rocky 10.2
primary_ip=10.0.2.15

More 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:

jinja2
{% 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:

output
log_level=error
debug=false

For 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:

jinja2
backends={{ backend_servers | join(',') }}

{% for server in backend_servers %}
server {{ server }}
{% endfor %}

Sample output:

output
backends=web1.example.com,web2.example.com

server web1.example.com
server web2.example.com

Loop 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:

jinja2
# {{ 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:

jinja2
{% for host in groups['lab'] -%}
{{ hostvars[host]['ansible_facts']['default_ipv4']['address'] | default('127.0.0.1') }} {{ host }}
{% endfor %}

Rendered:

Sample output:

output
10.0.2.15 localhost

Without -, 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:

jinja2
docker_format={{ '{{' }} .NetworkSettings.IPAddress {{ '}}' }}

Larger literal block:

jinja2
{% 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:

yaml
- 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 %s

Full web server main config:

yaml
- 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 nginx

If validation fails, the task fails and the previous dest file stays in place.

Preview changes before applying them on production hosts:

bash
ansible-playbook basic-template.yml --check --diff

Sample output:

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.

yaml
- 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 %s

Notify Handlers When Templates Change

Handlers run only when the template task reports changed—not on every play.

yaml
- 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: reloaded

First 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.

TIP
If a template reports 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

jinja2
<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:

jinja2
[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.j2join 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

Frequently Asked Questions

1. What is the difference between template and copy in Ansible?

copy deploys a static file byte-for-byte. template renders a Jinja2 source file on the control node using variables and facts, then writes the result to the destination.

2. Where do Jinja2 template files live?

Usually in a templates/ directory next to your playbook or inside a role. Use a .j2 extension so template sources are easy to spot.

3. Do I need gather_facts for facts in templates?

Yes for ansible_facts collected at runtime. Magic variables such as inventory_hostname are available without gathering all facts.

4. When should I use validate with template?

Use validate on critical config such as sudoers fragments (visudo -cf %s), a full sshd or nginx main config, or any file where the validator matches the exact shape you deploy. It runs a command against the temp file before Ansible replaces the destination.

5. Why does my template task report changed every run?

Common causes include ansible_date_time in the rendered content, unstable variables, or whitespace that changes each render. Fix the template or variables so output is stable.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …