unit 6 · Making it real
Jinja2 templating & filters
One source file, twenty configs
you may want to read 13 · YAML pitfalls & the quoting rule , 16 · Variables & the precedence that matters first — but nothing is locked
tl;dr
- template renders a .j2 file with each host's variables on the control node; copy ships bytes verbatim.
- A filter transforms a value inside {{ }} via a pipe — default() and join are the everyday pair.
- {{ }} prints, {% %} controls, | transforms — that triad reads any .j2 file you will meet.
why it matters Config files are where automation earns its keep — real estates run on .j2 templates, and misreading one means shipping a broken config to every host at once.
You have been writing Jinja2 since lesson 2 — every {{ }} you’ve typed
was the Jinja2 engine substituting a value into a string. This lesson
names the engine and hands you its two remaining pieces: whole-file
templates, and filters.
template vs copy
Lesson 12’s ansible.builtin.copy ships a file byte-for-byte — identical
on every host. ansible.builtin.template takes a
template — a .j2 file — renders it with the target
host’s variables and facts, and ships the result: one source, a
different render per host. The .j2 itself never lands anywhere.
- name: Render the app config
ansible.builtin.template:
src: app.conf.j2 # read on the CONTROL node
dest: /etc/app/app.conf # the rendered result lands here
mode: '0644'Inside the .j2, two markers do all the work: {{ }} prints a value,
{% %} is control flow. Values come from everything lesson 16 taught —
group_vars, host_vars, defaults — plus any fact
gathered from the host being rendered for:
# managed by Ansible — edits here get overwritten
listen_port = {{ app_port }}
node_name = {{ ansible_facts['hostname'] }}
{% for backend in backend_hosts %}
backend {{ backend }}:{{ app_port }}
{% endfor %}The for block repeats once per element of backend_hosts. A host whose
group defines three backends gets three backend lines; a host with none
gets zero. Each host’s own hostname fact lands in node_name. That’s
the pitch: one source file where you used to maintain one hand-edited
config per server. Same instinct as one Bicep file deployed with
different parameter files per environment.
Filters
A filter transforms a value inside {{ }}, applied
with a pipe. Two carry most of the daily load — default() for fallbacks
and join for list-to-string — with to_nice_json and regex_replace
close behind:
# fallback when the variable is UNDEFINED
port: '{{ app_port | default(8080) }}'
# list -> one delimited string
servers: "{{ backend_hosts | join(',') }}"
# dict -> readable JSON, straight into a config
settings_json: '{{ app_settings | to_nice_json }}'
# regex surgery on strings
short: "{{ inventory_hostname | regex_replace('^web-', '') }}"Every one of those values starts with {{, so every one is quoted —
lesson 13’s rule. Filters chain left to right:
{{ hosts | select('match', '^db-') | join(',') }} keeps only the
matching elements, then joins them. Other names worth recognising on
sight: to_json/to_yaml (serialise), map (extract or transform each
element), selectattr (filter a list of dicts by an attribute), and
combine (merge dicts).
Lookups at render time
Filters transform values you already have; lookups fetch values you
don’t. Lesson 14 taught the key fact: lookup() is a controller-side
plugin . That holds inside templates. Rendering happens
on the control node, so {{ lookup('file', 'banner.txt') }} in a .j2
reads the controller’s filesystem mid-render — only the finished text
reaches the host. Handy for injecting a shared key or banner; a classic
surprise when you expected the target’s file.
Templates get a permanent home in lesson 24: roles keep them in a
templates/ directory, where src: app.conf.j2 resolves automatically.
Until then, build the reading habit on every .j2 you meet:
{{ }} prints, {% %} controls, | transforms. Read those three and an
unfamiliar nginx.conf.j2 stops being someone else’s magic.
self-check — recall, not a test
1 copy and template both put a file at dest. What does template do that copy never will, and where does it happen?
2 app_port is unset for one group and their config renders with a blank port. Which filter fixes it, and what is its blind spot?
3 A .j2 file contains {{ lookup('file', 'banner.txt') }}. Which machine's banner.txt gets read, and why?
lab (optional — never required)
Template a .j2 with one scalar and one for loop over a list onto localhost, then unset the scalar and watch default() save the render.
needs: The control node from lesson 5.