unit 6 · Making it real

Jinja2 templating & filters

One source file, twenty configs

essential · 7 min · linux + windows

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'
same shape as a copy task — src is the .j2, dest gets the render

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 %}
app.conf.j2 — one loop stanza per list element

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-', '') }}"
the everyday filters, in template or playbook alike

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. 1 copy and template both put a file at dest. What does template do that copy never will, and where does it happen?

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

next up 24 · Roles: structure, defaults vs vars 8 min · unit 6