CoursesAnsibleTemplates & Jinja2

Templates & Jinja2

Generate config files from data.

Intermediate12 min · lesson 6 of 12

A template is a form letter. The bank writes one letter with your name, your balance and your branch left as blanks, then a machine fills those blanks from a customer list and posts ten thousand slightly different letters. The ansible.builtin.template module is that machine, for config files. You write nginx.conf.j2 once, boilerplate spelled out and the changeable parts marked, and Ansible produces a finished file for every host from that host's own variables and facts (facts being the details Ansible measures about each machine at the start of a run: CPU count, addresses, operating system). Ten servers, ten configs, one file you maintain.

The machine doing the filling is Jinja2, a templating language borrowed from the Python world. It substitutes values. It also decides and repeats. An if block can leave the whole TLS (Transport Layer Security, the encryption behind https) section out of the file on a host with no certificate. A for loop can write one server line per backend from a list in your inventory. The previous lesson covered where variable values come from. This one is about turning them into a file that a running service will accept.

Rendering Happens On Your Machine, Not Theirs

Here is the part that catches people out. Jinja2 never runs on the server you are configuring. Ansible reads the .j2 file on the control node (the laptop or build runner where you typed ansible-playbook), renders it there against that host's variables, and ships the finished text over SSH (Secure Shell, the encrypted remote login protocol). The managed host needs Python so the module can do its work, but it needs nothing at all for templating. No Jinja2 package. No template file. No copy of your variables. What crosses the network is an ordinary config file.

That split is a security fact, not trivia. Every secret that ends up in a rendered file passes through the control node in the clear, because that is where your Vault-decrypted variables live (Vault being Ansible's encrypted-file feature for secrets) and where the expressions get evaluated. The same split is why one template can mix facts from the host, values from group_vars (the per-group variable files in your repo), and a lookup that reads a file on your own laptop, all in a single line. The control node holds the keys, the secrets, and the code execution.

terminal
$ tree roles/nginx
output
roles/nginx
├── defaults
│ └── main.yml
├── handlers
│ └── main.yml
├── tasks
│ └── main.yml
└── templates
└── nginx.conf.j2
4 directories, 4 files
roles/nginx/tasks/main.yml
- name: Render nginx.conf for this host
ansible.builtin.template:
src: nginx.conf.j2 # no path needed: found in the role's templates/ dir
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644" # quote it; an unquoted 644 is decimal and lands as 01204
backup: true # keep the old file on the target before overwriting
validate: nginx -t -c %s # %s becomes a temp path ON THE MANAGED HOST
become: true
notify: Reload nginx # queue the handler, but only if the file changed

A bare src like nginx.conf.j2 resolves because the module looks inside the role's templates/ directory first. Outside a role, src is relative to the playbook's own directory, or you give an absolute path. Leave mode out and you have handed the decision to somebody else. A destination that does not exist yet is created according to the target's umask (the setting that strips permission bits off every newly created file), usually landing on 0644, readable by every account on the box. A destination that already exists keeps whatever mode somebody set last year. Any rendered file carrying a password should say so out loud: owner: app, group: app, mode: "0640".

What template actually does, in order
1Collect values
group_vars, host_vars, facts, vault
2Render locally
Jinja2 runs on the control node
3Compare checksums
identical? stop, report ok, notify nothing
4Ship to a temp path
on the target, not the final path
5Run validate there
non-zero exit fails the task, live file untouched
6Move into place
then queue the handler for end of play
Only the finished text crosses the network. The managed host never sees Jinja2, your variables, or the .j2 file.

What Goes Inside The .j2 File

roles/nginx/templates/nginx.conf.j2
# {{ ansible_managed }}
worker_processes {{ nginx_workers | default(ansible_facts['processor_vcpus']) }};
events {
worker_connections {{ nginx_conns | default(1024) }};
}
http {
upstream app_pool {
{% for be in app_backends %}
server {{ be.host }}:{{ be.port }} max_fails=3; # {{ loop.index }} of {{ app_backends | length }}
{% endfor %}
}
server {
listen {{ http_port | default(80) }};
server_name {{ site_name | mandatory }};
root /var/www/{{ site_name }};
{% if enable_tls | default(false) %}
listen 443 ssl;
ssl_certificate /etc/ssl/certs/{{ site_name }}.pem;
ssl_certificate_key /etc/ssl/private/{{ site_name }}.key;
ssl_protocols {{ tls_versions | join(' ') }};
{% endif %}
location / {
proxy_pass http://app_pool;
}
}
}

The blanks are expressions, not names. A filter is a small function you pipe a value through, using the same bar character a shell uses for the same idea, and four of them do most of the work here. default(1024) supplies a fallback when nobody set the variable, which is what lets a role work out of the box. Watch its edge. default only fires when the variable is undefined, so an empty string or a false value sails straight through unless you write default('auto', true), where that second argument means treat anything falsy as missing. mandatory is the opposite instinct. It stops the render dead with Mandatory variable 'site_name' not defined, which is what you want on a hostname or a certificate path. join(' ') flattens a list into the space-separated string nginx wants for ssl_protocols. And to_nice_yaml or to_nice_json dumps a whole dictionary from group_vars as valid structured text, turning a feature_flags map into a real config section with no loop at all.

The for loop repeats its body once per item in app_backends, so a list of two dictionaries produces two server lines and a list of nine produces nine. Inside the block, loop.index is a counter starting at 1 (loop.index0 counts from zero) and app_backends | length is the total. Watch the edges here too, because this is where a generated config bites. An undefined app_backends aborts the render with an error you see immediately. An empty list renders an empty upstream block, which Jinja2 is perfectly happy with and nginx rejects with no servers are inside upstream. An if guard, or default([]), decides which of those two failures you get.

The first line stamps the file. ansible_managed renders as the string Ansible managed by default, a note to the next human telling them their hand edit will be erased on the next run. You supply the comment character yourself, because Ansible has no idea whether this file marks comments with a hash, a double slash or a semicolon. Resist making it fancier. ansible.cfg lets you redefine ansible_managed to include a timestamp, and the second you put a clock in there the rendered content differs on every run. Every run then reports changed, every run fires the handler, and nginx reloads forever for no reason.

Look At The Diff Before It Lands

Check mode is a dress rehearsal. --check walks the whole play and changes nothing, and --diff prints what each task would alter. For a template that diff is the entire point, because it shows a unified diff (the before-and-after format with minus and plus signs in front of changed lines) between the file living on the host right now and the exact text Ansible rendered for it. Add --limit so you are reading one host and not forty.

terminal
$ ansible-playbook -i inventory.ini site.yml --limit web1.acme.internal --check --diff
output
PLAY [Configure web servers] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1.acme.internal]
TASK [nginx : Render nginx.conf for this host] *********************************
--- before: /etc/nginx/nginx.conf
+++ after: /home/deploy/.ansible/tmp/ansible-local-4127hj3k9p1x/tmpq8w2b_ff/nginx.conf.j2
@@ -1,5 +1,5 @@
# Ansible managed
-worker_processes 2;
+worker_processes 4;
events {
worker_connections 1024;
@@ -7,7 +7,8 @@
http {
upstream app_pool {
- server 10.0.1.11:8080 max_fails=3; # 1 of 1
+ server 10.0.1.11:8080 max_fails=3; # 1 of 2
+ server 10.0.1.12:8080 max_fails=3; # 2 of 2
}
server {
changed: [web1.acme.internal]
RUNNING HANDLER [nginx : Reload nginx] *****************************************
changed: [web1.acme.internal]
PLAY RECAP *********************************************************************
web1.acme.internal : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Three things in that output are worth reading properly. The before path is the live file on the server and the after path is a temp file on your own machine, which confirms the direction of travel. The handler ran, because check mode still notifies handlers and runs them (in check mode themselves), so you see the reload a real run would perform without it actually happening. And notice what is missing. validate never ran, because check mode transfers nothing to the host at all. Check mode tells you what would change. It does not tell you the result would be accepted.

terminal
$ ansible-playbook -i inventory.ini site.yml --limit web1.acme.internal
output
PLAY [Configure web servers] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1.acme.internal]
TASK [nginx : Render nginx.conf for this host] *********************************
changed: [web1.acme.internal]
RUNNING HANDLER [nginx : Reload nginx] *****************************************
changed: [web1.acme.internal]
PLAY RECAP *********************************************************************
web1.acme.internal : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
terminal
$ ansible-playbook -i inventory.ini site.yml --limit web1.acme.internal # again, nothing edited in between
output
PLAY [Configure web servers] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1.acme.internal]
TASK [nginx : Render nginx.conf for this host] *********************************
ok: [web1.acme.internal]
PLAY RECAP *********************************************************************
web1.acme.internal : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

The second run is your real proof. changed=0 means the checksum (a short fingerprint calculated from the file's contents) of the freshly rendered text matched the file already sitting on the host, so nothing was written and no handler fired. The ok count dropped by one because the reload never happened. That is idempotence (running it twice changes nothing the second time), and it is what makes this play safe to run on a schedule rather than only when a human is watching. A template that reports changed on every run is a machine that reloads production on a timer.

Validate Runs On The Managed Host

Validation is the taste test before the plate leaves the kitchen. Ansible copies the rendered file to a temporary path on the managed host, runs your command against that path, and replaces the live file only if the command exits zero. The %s in validate: nginx -t -c %s is where that temp path gets substituted, and a validate string with no %s in it makes the module refuse to run at all. Two details bite people. The command runs on the target, not on your laptop, so the binary has to exist over there. And it runs directly rather than through a shell, so pipes and redirects inside it do nothing useful.

terminal
# somebody fat-fingers a value: worker_processes becomes "4 8"
$ ansible-playbook -i inventory.ini site.yml --limit web1.acme.internal \
-e '{"nginx_workers": "4 8"}'
output
PLAY [Configure web servers] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1.acme.internal]
TASK [nginx : Render nginx.conf for this host] *********************************
fatal: [web1.acme.internal]: FAILED! => {"changed": false, "exit_status": 1, "msg": "failed to validate", "stderr": "nginx: [emerg] invalid number of arguments in \"worker_processes\" directive in /home/deploy/.ansible/tmp/ansible-tmp-1753098412.61-9182-183459827364/source:2\nnginx: configuration file /home/deploy/.ansible/tmp/ansible-tmp-1753098412.61-9182-183459827364/source test failed\n", "stderr_lines": ["nginx: [emerg] invalid number of arguments in \"worker_processes\" directive in /home/deploy/.ansible/tmp/ansible-tmp-1753098412.61-9182-183459827364/source:2", "nginx: configuration file /home/deploy/.ansible/tmp/ansible-tmp-1753098412.61-9182-183459827364/source test failed"], "stdout": "", "stdout_lines": []}
PLAY RECAP *********************************************************************
web1.acme.internal : ok=1 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0

Read what did not happen. changed is false. The live /etc/nginx/nginx.conf was never touched, the temp file was cleaned up, the handler never fired, and the play stopped on that host. Strip the validate line out and the same typo writes a broken config, notifies the handler, and the reload blows up in front of you. On a host where nothing reloads, it is worse. The box now carries a landmine until an unrelated restart three weeks later takes nginx down and nobody connects the two events.

terminal
$ ansible web1.acme.internal -i inventory.ini -m ansible.builtin.command -a "nginx -t" --become
output
web1.acme.internal | CHANGED | rc=0 >>
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test successful

Be honest about what validate can and cannot check. nginx -t -c %s treats the temp file as a complete main config, which is right for nginx.conf and wrong for a virtual host fragment dropped into conf.d/, where a bare server block at the top level fails with "server" directive is not allowed here. For fragments, validate the assembled config in a later task, or accept that the handler's reload is your test. The one file where you should never skip validation is /etc/sudoers, with validate: /usr/sbin/visudo -csf %s. A malformed sudoers file means nobody on that host can become root again, including Ansible, which is how you would have fixed it.

Whitespace Is Where Templates Actually Break

The template renders. The service rejects it. Most of the time the culprit is whitespace you cannot see in your editor. Ansible turns on a Jinja2 option called trim_blocks, which swallows the newline immediately after a closing block tag, and leaves lstrip_blocks off, which means every space you typed in front of an opening block tag is copied into the output as a real character. Indent your tags to line up with the surrounding structure, the way any sane person would, and you quietly shift the next line to the right.

roles/app/templates/app-config.yml.j2
logging:
level: {{ log_level | default('info') }}
{% if debug_mode | default(false) %}
verbose: true
{% endif %}
format: json
preview.yml
# Render one host's template onto the control node so you can stare at it.
# Facts still come from the real host; only the write is local.
- name: Preview rendered config
hosts: web
gather_facts: true
tasks:
- name: Write the rendered file to /tmp on the control node
ansible.builtin.template:
src: roles/app/templates/app-config.yml.j2
dest: "/tmp/{{ inventory_hostname }}-app-config.yml"
mode: "0600"
delegate_to: localhost
terminal
$ ansible-playbook -i inventory.ini preview.yml --limit web1.acme.internal > /dev/null
$ cat -A /tmp/web1.acme.internal-app-config.yml # -A marks every line end with $
$ python3 -c 'import yaml,sys; yaml.safe_load(open(sys.argv[1]))' /tmp/web1.acme.internal-app-config.yml
output
logging:$
level: info$
format: json$
Traceback (most recent call last):
File "<string>", line 1, in <module>
...
yaml.scanner.ScannerError: mapping values are not allowed here
in "/tmp/web1.acme.internal-app-config.yml", line 3, column 11

Count the spaces. The two in front of the if tag were never part of the if, so they got printed, then trim_blocks ate the newline after the tag and glued the next line on behind them. format: json now sits at four spaces while level: info sits at two, and YAML (the indentation-sensitive data format Ansible itself is written in) refuses the file. Here is the nasty part. debug_mode was false, so the branch you were testing never ran. The template broke on the path you never looked at.

roles/app/templates/app-config.yml.j2
#jinja2: trim_blocks: True, lstrip_blocks: True
logging:
level: {{ log_level | default('info') }}
{% if debug_mode | default(false) %}
verbose: true
{% endif %}
format: json
terminal
$ ansible-playbook -i inventory.ini preview.yml --limit web1.acme.internal > /dev/null
$ cat -A /tmp/web1.acme.internal-app-config.yml
$ python3 -c 'import yaml,sys; yaml.safe_load(open(sys.argv[1]))' /tmp/web1.acme.internal-app-config.yml ; echo "exit=$?"
output
logging:$
level: info$
format: json$
exit=0

That header has to be the very first line of the template. Ansible reads it, strips it off, and renders what is left, so it never reaches the output. It parses those values as Python literals, which is why they are True and False with capital letters rather than YAML-style true. Two other routes reach the same place. A minus sign inside a tag chomps whitespace on that side, so {%- if debug_mode %} eats what comes before it and {% endif -%} eats what follows: the surgical option. Or set lstrip_blocks: true and trim_blocks: true as options on the template task, which is more visible to whoever reads the play, at the cost of the setting no longer travelling with the template. Pick one convention per repository and stop thinking about it.

The branch you did not test is the one that breaks
Whitespace bugs hide inside conditionals, because the broken output only appears for the combination of variables you did not render. Before you trust a template, render it both ways: run the preview play with -e debug_mode=true and again with -e debug_mode=false, and look at both files. If the target file is whitespace-sensitive (YAML, a Python config, a hosts file, an INI file where an indented line becomes a continuation of the line above it) put a real parser in the validate option or in your pipeline. A config file that parses on Tuesday and not on Wednesday because a flag flipped is a genuinely miserable outage to debug.

A Variable That Renders Itself

Rendering means evaluating expressions. That is the feature, and it is also the hole. Ansible templates variable values recursively, so if the value of a variable contains Jinja2 syntax, that syntax gets evaluated too, on the control node, as the user running the play. A value that arrives from somewhere you do not control is code, not data.

group_vars/web.yml
# banner_raw came out of a self-service form that nobody reviewed
banner_raw: "Welcome to {{ lookup('ansible.builtin.pipe', 'id') }}"
# the !unsafe tag tells Ansible to treat the string as literal text, never as Jinja2
banner_safe: !unsafe "Welcome to {{ lookup('ansible.builtin.pipe', 'id') }}"
terminal
$ ansible localhost -i inventory.ini -e @group_vars/web.yml \
-m ansible.builtin.debug -a "msg={{ banner_raw }}"
$ ansible localhost -i inventory.ini -e @group_vars/web.yml \
-m ansible.builtin.debug -a "msg={{ banner_safe }}"
output
localhost | SUCCESS => {
"msg": "Welcome to uid=1000(deploy) gid=1000(deploy) groups=1000(deploy),27(sudo)"
}
localhost | SUCCESS => {
"msg": "Welcome to {{ lookup('ansible.builtin.pipe', 'id') }}"
}

The first command ran id on the control node, the machine holding SSH keys and sudo rights for the whole fleet. Swap id for something less friendly and you have the shape of the attack: an inventory built from cloud tags, a variables file generated by a ticketing system, an operator-supplied hostname. Ansible blocks the best-travelled path already, because values returned by lookup plugins are wrapped in a type called unsafe and never get re-rendered. Everything else is on you, and !unsafe is the tool. The same recursion has a harmless cousin. When the file you are generating contains double braces of its own, a Grafana dashboard or another tool's template, wrap that region in a {% raw %} block or change the delimiters with the module's variable_start_string and variable_end_string options.

--diff prints the secret straight into the log
A rendered file with a database password in it is exactly what --diff prints, line by line, into your terminal and into the pipeline log that half the company can read six months from now. Ansible has no idea which lines are sensitive. Set no_log: true on tasks that render secrets and you lose the diff along with the rest of the task detail, or set diff: false on that one task to keep normal output while suppressing that file's content. Pair it with a real owner and mode: "0640", because a rendered file with no mode set is created according to the target's umask, which usually means world-readable 0644, and a service account you have never heard of can read it.

Handlers, Drift, And What Template Will Not Do

notify is a note pinned to a board, not an order shouted across the room. When the task reports changed, the named handler joins a queue, and that queue runs once at the end of the play, after every ordinary task has finished. Ten tasks notifying Reload nginx produce exactly one reload. A task reporting ok notifies nothing, which is the only reason your fleet is not reloading nginx every hour on the hour.

roles/nginx/handlers/main.yml
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded

There is a trap in that queue. If a later task in the play fails on a host, that host's pending handlers are thrown away. You end up with a new config file on disk and the old config still loaded in memory, so the change lands silently at the next unrelated restart, days later. force_handlers: true on the play (or --force-handlers on the command line) runs the queued handlers even after a failure. Turn it on for any play that writes configuration. And keep backup: true on the template task, because it leaves the previous version beside the new one, named like /etc/nginx/nginx.conf.4127.2026-07-21@14:03:11~. That path is what you copy back from at two in the morning.

Now the honest trade-off. The template module owns the whole file. Every run overwrites dest with the rendered result, and whatever an engineer typed by hand during an incident vanishes. That is the point: drift stops existing, and the file in Git is the file on the box. It is also the limit. When a file is genuinely shared, say the SSH daemon's sshd_config that your compliance agent also edits, the template and that other writer overwrite each other in turns and the last one to run wins. Then you drop to ansible.builtin.blockinfile, which manages a marked region and leaves the rest alone, or ansible.builtin.lineinfile for a single directive. Both are weaker promises. You know your region is right, you do not know the file is right. Own the whole file whenever you can.

Quick check
01Where does Jinja2 render your template, and what does that require of the managed host?
Incorrect — The managed host needs Python for the module itself, but never sees Jinja2 or the .j2 file.
Correct — Rendering is local to the control node, which is also why that machine sees every secret in the clear.
Incorrect — delegate_to changes where the resulting file gets written, not where Jinja2 runs; rendering is always local.
Incorrect — That temp directory holds the already-rendered file waiting for validation, not a template being processed.
02Your task uses validate: nginx -t -c %s. Where does that command run, and what happens when it exits non-zero?
Incorrect — Wrong twice over: the command runs on the managed host, and a non-zero exit stops the copy dead.
Incorrect — Validation happens before the move into place, which is exactly what makes it protective.
Incorrect — Backwards. Check mode transfers nothing to the host, so validate is the one thing check mode cannot exercise.
Correct — The %s is that temp path, the command runs on the target, and a failure leaves the working config untouched.
03A template task reports changed on every single run and reloads nginx each time, even though nobody has edited the template or the variables. The template's first line is # {{ ansible_managed }}, and ansible.cfg contains ansible_managed = Ansible managed: rendered %Y-%m-%d %H:%M:%S. What is the right fix?
Incorrect — That silences the symptom and also stops the handler from ever firing on a change that genuinely matters.
Incorrect — That blocks every future update to the file, including the ones you actually want applied.
Correct — Change detection compares the rendered text against the file on the host, and a clock in that text guarantees a mismatch.
Incorrect — Backups have no effect on change detection; you would only fill the directory with a fresh copy on every run.

No linter will save you here. ansible-lint reads the Jinja2 expressions inside your YAML task arguments, not the contents of your .j2 files, so a stray indent or a loop over an empty list walks straight past it. The render is the test. Put a --check --diff run against one representative host from each group into your pipeline, read the diff with your own eyes, and only then let the real run go.

Try this

Run tree roles/nginx on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: the branch you did not test is the one that breaks. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related