Red Hat Certified Engineer (RHCE) #7: Jinja2 Templates — Filters, Control Flow, and lookup

4 min read

In #6 Variables and facts you learned to handle values that differ per host with variables and facts. To stamp those values into actual configuration files, you need templates. Ansible ships with the Jinja2 template engine built in, so it renders a .j2 file with variables embedded in it on a per-host basis to dynamically generate /etc/hosts or server config files. In this post we’ll organize the template module, Jinja2 syntax, filters, and lookup in the form the exam asks for.

Why you need templates #

The same configuration file often has to carry different values per host. A web server’s ServerName must follow the host’s name, and /etc/hosts must hold the IP and name of every host in the inventory. Building such files by hand for each host breaks idempotency and eats time.

The solution is to build one template with variables embedded in it and render it with per-host values. The copy module copies a file as-is, but the template module substitutes variables and runs control flow with the Jinja2 engine before copying. That is the decisive difference between copy and template.

The template module #

The template module renders a .j2 file on the control node and writes it to the destination on the managed node. By convention, template files go in the templates/ directory of a role or playbook, and the extension is .j2.

Basic template module usage
- name: 호스트별 설정 파일 생성
  ansible.builtin.template:
    src: motd.j2
    dest: /etc/motd
    owner: root
    group: root
    mode: '0644'

For src, write the path relative to templates/ and Ansible finds it automatically. dest is the absolute path on the managed node. You set ownership and permissions together with owner, group, and mode, and the options are nearly identical to the copy module.

Blocking bad config with validate #

If a server config file’s syntax is wrong, the service fails to start. The template module’s validate runs a validation command before moving the file to the destination, and applies it only if it passes. The temporary file path is inserted in place of %s.

Deploy with validate
- name: sshd 설정 배포(검증 후 적용)
  ansible.builtin.template:
    src: sshd_config.j2
    dest: /etc/ssh/sshd_config
    owner: root
    group: root
    mode: '0600'
    validate: /usr/sbin/sshd -t -f %s
  notify: restart sshd

The validation command differs per service, as in visudo -cf %s, nginx -t -c %s, or httpd -t. Since it prevents an incident where a service dies from bad config, attach validate to config-deployment tasks whenever possible.

Jinja2 basic syntax #

Jinja2 has three delimiters. Once you can tell these three apart, you can read almost any template.

DelimiterPurpose
{{ ... }}Output a variable or expression
{% ... %}Control statements like for and if
{# ... #}Comments (not rendered)

Variable output #

Write a variable name inside {{ }} and its value goes into the rendered result. With dot notation you can also reach the sub-values of a dictionary or fact.

templates/motd.j2
{# templates/motd.j2 #}
Welcome to {{ inventory_hostname }}.
OS: {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}
Managed by Ansible. Do not edit by hand.

inventory_hostname is the host’s name as written in the inventory, and ansible_facts is the auto-collected facts covered in #6. Deploying this template with the template module produces an /etc/motd stamped with each host’s name and OS info.

if control flow #

{% if %} changes the output based on a condition. A {% endif %} is always required at the end.

if branching
{% if ansible_facts['memtotal_mb'] > 4096 %}
worker_processes auto;
{% else %}
worker_processes 2;
{% endif %}

This handles a per-host branch within one template — set workers automatically if memory exceeds 4GB, otherwise fix it at 2.

for loops #

{% for %} iterates over a list or dictionary. The pattern of generating multiple config lines through a loop is a regular on the exam.

for loop
{% for user in allowed_users %}
AllowUsers {{ user }}
{% endfor %}

If allowed_users is a list, one AllowUsers line is produced per element. Inside the loop you can also use special variables like loop.index (starting at 1), loop.first, and loop.last.

Generating /etc/hosts from host info #

The form that comes up most often on the exam is the task of building /etc/hosts from every host in the inventory. You combine the magic variables groups and hostvars.

templates/hosts.j2
{# templates/hosts.j2 #}
127.0.0.1   localhost localhost.localdomain

{% for host in groups['all'] %}
{{ hostvars[host]['ansible_facts']['default_ipv4']['address'] }}   {{ hostvars[host]['inventory_hostname'] }}
{% endfor %}

groups['all'] is the list of every host name in the inventory. You can also iterate over just a specific group, as in groups['web']. hostvars[host] is the channel to all of that host’s variables and facts, so you can pull another host’s IP and gather them into a single file.

For this pattern to work, the facts of the hosts you iterate over must have been collected first. If you run a play against only some hosts but need the facts of all hosts, add an earlier play that passes through every host once, or keep gather_facts on.

Jinja2 filters #

A filter transforms a value by attaching it after |. Here are the filters worth memorizing for RHCE.

default: a fallback when the variable is absent #

default filter
Listen {{ http_port | default(80) }}

If http_port is not defined, it uses 80. To also replace an empty string even when it is defined, pass a second argument, as in default(80, true).

upper, lower, join: string manipulation #

upper, lower, join filters
ENV={{ env_name | upper }}
region={{ aws_region | lower }}
SUPPORTED_DB={{ db_list | join(', ') }}

upper and lower change case, and join stitches a list together with a separator. If db_list is ['mysql', 'postgres'], it merges into mysql, postgres, which is handy for unrolling a list onto one line without using a for loop.

length: counting #

length filter
# total managed hosts: {{ groups['all'] | length }}

It returns the number of elements in a list, dictionary, or string. It’s also used in a when condition to check whether something is empty, as in groups['web'] | length > 0.

to_nice_yaml: a data structure as YAML #

It pretty-prints a dictionary or list as a YAML-formatted string. Useful for dumping a config wholesale.

to_nice_yaml filter
{{ app_config | to_nice_yaml }}

Similarly, to_nice_json outputs as JSON, while to_yaml and to_json output without line breaks.

mandatory: force a failure on a missing variable #

mandatory filter
db_password={{ vault_db_password | mandatory }}

If the variable is not defined, it doesn’t pass through like default does but fails immediately with an error. Attach it to a value that absolutely must exist, to prevent an incident where an empty config is deployed silently.

Reading external values with lookup #

lookup reads an external source such as a file or an environment variable on the control node. You use it both inside a playbook and inside a template.

lookup examples
- name: 로컬 공개키를 변수로 읽기
  ansible.builtin.set_fact:
    my_pubkey: "{{ lookup('file', '/home/curtis/.ssh/id_rsa.pub') }}"

- name: 환경 변수 읽기
  ansible.builtin.debug:
    msg: "{{ lookup('env', 'HOME') }}"

lookup('file', path) reads the contents of a file on the control node as-is, and lookup('env', name) reads an environment variable on the control node. Inside a template you embed external content the same way, as in {{ lookup('file', '/etc/banner.txt') }}.

Whitespace control #

Because a Jinja2 {% %} block takes up a line, using a for or if tends to leave a blank line in the result. Adding a - inside the delimiter removes the whitespace and line break in that direction.

Whitespace control
{% for port in open_ports -%}
-A INPUT -p tcp --dport {{ port }} -j ACCEPT
{% endfor -%}

{%- removes leading whitespace, and -%} removes trailing whitespace. You can clean things up neatly when no unnecessary blank lines may go into a config file. That said, overusing it hurts readability, so we’ll use it only when the format of the result file matters.

Full example: per-host web server config #

Let’s tie the elements covered so far into one flow. First, the template.

templates/vhost.conf.j2
{# templates/vhost.conf.j2 #}
{# 호스트별 가상 호스트 설정 #}
<VirtualHost *:{{ http_port | default(80) }}>
    ServerName {{ inventory_hostname }}
    ServerAdmin {{ admin_email | default('root@localhost') }}
    DocumentRoot /var/www/{{ inventory_hostname }}

{% for alias in server_aliases | default([]) %}
    ServerAlias {{ alias }}
{% endfor %}
</VirtualHost>

Next, the playbook that deploys this template.

vhost deployment playbook
- name: 웹 서버 가상 호스트 구성
  hosts: web
  become: true
  vars:
    http_port: 8080
    admin_email: webmaster@example.com
    server_aliases:
      - www
      - app
  tasks:
    - name: vhost 설정 배포
      ansible.builtin.template:
        src: vhost.conf.j2
        dest: /etc/httpd/conf.d/vhost.conf
        owner: root
        group: root
        mode: '0644'
        validate: /usr/sbin/httpd -t
      notify: restart httpd

  handlers:
    - name: restart httpd
      ansible.builtin.service:
        name: httpd
        state: restarted

For each host in the web group, ServerName and DocumentRoot are filled in with its own host name, and the server_aliases list unrolls into ServerAlias lines. It’s applied only if it passes the httpd -t check via validate, and the handler restarts httpd only if the config changed.

Exam points #

  • The difference between copy and template. Use template when you need variable substitution and control flow; use copy for static files.
  • template’s src is relative to templates/. Ansible finds it without an absolute path. The extension is .j2 by convention.
  • owner, group, mode, validate. Get into the habit of specifying permissions and validation together on config-deployment tasks.
  • The three delimiters. Distinguish {{ }} for output, {% %} for control, and {# #} for comments.
  • The groups and hostvars combination. For the type that builds /etc/hosts from the whole inventory, also check whether facts have been collected.
  • The frequently used filters. Get default, upper, lower, join, length, to_nice_yaml, and mandatory into your hands.
  • lookup. lookup('file', path) and lookup('env', name) read relative to the control node.
  • Whitespace control. Use - to remove the blank lines left by for and if.

Wrap-up #

What this post locked in:

  • Render .j2 files into per-host config with the template module, specifying owner, group, mode, and validate together
  • Jinja2 syntax. {{ }} for variable output, {% if %} and {% for %} for control flow, {# #} for comments
  • Filters. Transform values and block omissions with default, upper, lower, join, length, to_nice_yaml, and mandatory
  • Generate an /etc/hosts or server config that holds the whole inventory with a groups and hostvars loop
  • Read the control node’s files and environment variables with lookup, and refine the result’s format with whitespace control

Next: Error handling #

We’ve locked in how to dynamically generate config with templates. But a playbook stops the rest of a host’s tasks if even one task fails. There are plenty of situations where a validation command fails or you need to ignore an error only under a certain condition.

In #8 Error handling: block/rescue/always, failed_when, ignore_errors, we’ll organize how to handle exceptions structurally with block/rescue/always, how to define success/changed judgments yourself with failed_when and changed_when, and how to skip a specific failure with ignore_errors — all in the form the exam asks for.

X