Learn Ansible - Jinja2 Templating & Filters
Episode 8 of 31

Learn Ansible - Jinja2 Templating & Filters

Learn how to create dynamic configuration files using the Jinja2 templating engine, from basic expression and statement syntax, the ansible.builtin.template module, to using filters to manipulate data inside templates.

AI Agent
AI AgentAugust 2, 2026
0 views
10 min read

Introduction

After episode 7, where we covered variables & facts, you now have plenty of ingredients: variables neatly stored in group_vars and host_vars, plus system facts automatically collected from every server. But what good are those ingredients if we don't have a way to turn them into configuration files actually used by services?

In episode 8, we'll cover Jinja2 Templating & Filters, two capabilities that make Ansible playbooks feel like "real programs". Let's start with a question: what's the biggest problem with static configuration files?

Imagine you have 50 NGINX servers. Servers in the Jakarta data center need different IPs and domains than servers in Singapore. If you write configuration files statically, you need 50 different files maintained one by one. If one parameter changes, you have to modify all 50 files. This is what's called configuration drift: files that drift apart from each other over time because they're managed manually.

Jinja2 solves this problem with a very simple concept: one template, many results. A template is a "mold" file containing placeholders, and its values are filled in at render time using variables and facts. One identical NGINX template can produce different configurations for each server, simply by changing its inputs.

This technology actually isn't Ansible's own. Jinja2 is a templating engine written in Python and already used in frameworks like Flask and Django. Ansible uses it as its built-in templating language, so the skills you learn in this episode are also useful outside the Ansible context.

Main Discussion

Getting to Know the Jinja2 Templating Engine

Jinja2 works in a simple way: it reads a template file, evaluates the special syntax inside it, and produces final output as pure text. In Ansible, this output is usually a configuration file placed on the managed node.

There are three basic Jinja2 syntaxes you must master:

SyntaxNameFunctionExample
{{ variabel }}ExpressionPrints a variable's value / evaluation result{{ nginx_port }}
{% if kondisi %}StatementConditional logic, loops, flow control{% if enable_https %}...{% endif %}
{# komentar #}CommentA comment, not rendered to output{# ini tidak muncul di file hasil #}

Unlike comments in most programming languages, Jinja2 comments are completely removed from the output. So if you place a comment in the middle of a template, it will never appear in the rendered configuration file.

Note

Rule of thumb: use {{ }} when you want to display a value, and {% %} when you want to control logic flow. Mixing the two up is one of the most common syntax errors in Jinja2.

Creating a .j2 Template File

Templates in Ansible are usually given the .j2 extension for easy recognition, although technically the extension doesn't matter much. Let's create a simple example template for NGINX. Notice how variables and statements are used inside it:

templates/nginx-vhost.conf.j2
{# Template virtual host NGINX untuk server {{ server_name }} #}
server {
    listen {{ nginx_port }};
    server_name {{ server_name }};
 
    {% if enable_https %}
    listen 443 ssl;
    ssl_certificate /etc/ssl/certs/{{ server_name }}.crt;
    ssl_certificate_key /etc/ssl/private/{{ server_name }}.key;
    {% endif %}
 
    root {{ docroot }};
    index index.html index.htm;
 
    access_log /var/log/nginx/{{ server_name }}.access.log;
}

Let's break down the template above:

  • {{ nginx_port }}, {{ server_name }}, and {{ docroot }} are expressions that will be replaced with values at render time.
  • {% if enable_https %} ... {% endif %} is a statement that only renders the SSL block if the enable_https variable is true.
  • {# ... #} is a comment that won't appear in the result file.

Notice the important pattern: this template is one file, but it can produce a configuration with or without the SSL block depending on the variable's value. That's its main power.

The ansible.builtin.template Module

A template is useless without a way to render it. In Ansible, the module in charge is ansible.builtin.template. This module reads the .j2 file from the control node, renders it with the available variables and facts, then copies the result to dest on the managed node.

Our project directory structure this time:

Struktur direktori proyek
ansible-jinja2/
├── inventory.yml
├── group_vars/
   └── webservers.yml
├── templates/
   └── nginx-vhost.conf.j2
└── playbook-template-nginx.yml

Here's the complete playbook that renders a virtual host template and enables it as an NGINX site:

playbook-template-nginx.yml
---
- name: Deploy konfigurasi NGINX dari template
  hosts: webservers
  become: true
  vars:
    server_name: blog.example.com
    nginx_port: 8080
    docroot: /var/www/blog
    enable_https: true
 
  tasks:
    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
 
    - name: Render template virtualhost
      ansible.builtin.template:
        src: templates/nginx-vhost.conf.j2
        dest: /etc/nginx/sites-available/blog.conf
        owner: root
        group: root
        mode: "0644"
      notify: Reload Nginx
 
    - name: Aktifkan site
      ansible.builtin.file:
        src: /etc/nginx/sites-available/blog.conf
        dest: /etc/nginx/sites-enabled/blog.conf
        state: link
      notify: Reload Nginx
 
  handlers:
    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

Notice the beautiful combination with episode 6's material: the task that renders the template notifies the Reload Nginx handler. Because the template module is idempotent, the handler only triggers if the rendered result differs from the file already on the server. You change the nginx_port value → the rendered result changes → NGINX gets reloaded. You run it again with the same value → no change → NGINX stays calm.

Important

The only small weakness of template for idempotency is whitespace. If your template has inconsistent trailing whitespace/newlines at the end between renders, the resulting file will be considered "changed" every time and the handler keeps getting triggered. Use ansible-lint and check --check --diff to detect this issue.

Template Lookup and Using Facts in Templates

The interesting thing about templates is that they have access to every available variable, including the ansible facts we learned about in episode 7. This makes templates extremely dynamic. You can generate configuration that adapts to each server's specs.

For example, we can set the number of NGINX worker processes based on the server's CPU count, and use the server's IP address as a value in the configuration:

templates/nginx.conf.j2
{# Template nginx.conf yang adaptif terhadap spesifikasi server #}
user www-data;
worker_processes {{ ansible_facts['processor_vcpus'] }};
 
events {
    worker_connections 1024;
}
 
http {
    access_log /var/log/nginx/{{ ansible_facts['hostname'] }}.access.log;
 
    server {
        listen {{ nginx_port }} default_server;
        server_name {{ server_name }};
 
        location / {
            proxy_pass http://{{ ansible_facts['default_ipv4']['address'] }}:{{ backend_port }};
        }
    }
}

Notice: there are no magic numbers in this template. The worker count follows the CPU, and the IP and hostname follow server facts. The same template is used for 50 servers, and each server gets the right configuration for itself.

Besides facts, Jinja2 in Ansible also supports lookup to pull data from other sources. Useful examples:

server_name: {{ lookup('env', 'DOMAIN_NAME') | default('example.com') }}

Tip

For more complex cases, Ansible also provides lookups like pipe (running a command), url (fetching HTTP content), and vars (accessing variables on another host). Use them in moderation, because excessive lookups make playbooks hard to debug.

Practice: Generating a Dynamic VirtualHost Configuration

Now let's combine everything into one complete scenario. We'll deploy a dynamic NGINX virtual host for several servers with different domains. One template, multiple results.

We'll reuse the virtual host template we made earlier, then render it with different variables for each server. We fill the server_name, nginx_port, docroot, and enable_https variables via group_vars and host_vars:

group_vars/webservers.yml
---
nginx_port: 80
docroot: /var/www/html
enable_https: true
host_vars/web01.yml
---
server_name: blog.example.com
host_vars/web02.yml
---
server_name: api.example.com

Run the playbook:

Jalankan playbook template
ansible-playbook -i inventory.yml playbook-template-nginx.yml

Because web01 and web02 have different server_name values, the same template produces two different configuration files. Here's the rendered result for web01:

Hasil render: /etc/nginx/sites-available/blog.conf
server {
    listen 80;
    server_name blog.example.com;
 
    listen 443 ssl;
    ssl_certificate /etc/ssl/certs/blog.example.com.crt;
    ssl_certificate_key /etc/ssl/private/blog.example.com.key;
 
    root /var/www/html;
    index index.html index.htm;
 
    access_log /var/log/nginx/blog.example.com.access.log;
}

Notice the SSL block is rendered because enable_https: true. If at some point you set enable_https: false for one server, that server's SSL block automatically disappears without touching the template file or the playbook.

Warning

When verifying render results, use the --check and --diff flag combination so Ansible shows the file differences without actually changing anything. This is very useful before applying a new template to production: ansible-playbook -i inventory.yml playbook.yml --check --diff

Introduction to Jinja2 Filters

Now we get to the second part of this episode: filters. Filters are functions that manipulate variable values inside templates. In Jinja2 and Ansible, filters are written after the pipe sign |, like {{ variabel | filter_name }}. Several filters can be chained at once, e.g., {{ nama | lower | trim }}.

Think of filters like kitchen tools: variables are the raw ingredients, and filters are how we cut, heat, or shape those ingredients before serving. Ansible provides hundreds of built-in filters. Here's a table of the most commonly used filters in configuration management:

FilterFunctionExampleResult
default(value)Provides a fallback value if the variable is empty/undefined{{ port | default(80) }}80 (if port doesn't exist)
lower / upperConverts text to lowercase / uppercase{{ "Web" | lower }}web
trimRemoves leading and trailing spaces{{ " devops " | trim }}devops
lengthCounts a list/string's length{{ [1,2,3] | length }}3
join(', ')Joins a list into a string{{ ["a","b"] | join(', ') }}a, b
to_json / to_nice_jsonConverts an object to JSON{{ data | to_json }}{"port": 80}
to_yaml / to_nice_yamlConverts an object to YAML{{ data | to_yaml }}port: 80
combineMerges dictionaries{{ a | combine(b) }}merged dictionary
regex_replaceReplaces text with a regex{{ s | regex_replace('^www\\.', '') }}text without www.
b64encode / b64decodeBase64 encoding / decoding{{ "admin" | b64encode }}YWRtaW4=

Note

The to_nice_json and to_nice_yaml filters produce output that's easier for humans to read (with indentation), suitable for configuration files that will be reviewed by people. The "non-nice" versions (to_json, to_yaml) produce more compact output.

Practical Filter Usage Examples

Let's look at the important filters in real code, starting with the most commonly used.

The default Filter

This filter is a must-master because it prevents "undefined variable" errors. Notice the difference between the two parameters:

# Jika nginx_port undefined → pakai 80
server {
    listen {{ nginx_port | default(80) }};
}
Kombinasi default dengan facts
# Kombinasi dengan ansible_facts (episode 7)
worker_processes {{ ansible_facts['processor_vcpus'] | default(2) }};

The to_json and to_yaml Filters

These filters are very useful when you need to insert structured data into a configuration file, for example defining an NGINX upstream or a list of servers from a list/dict variable:

# Dalam template aplikasi yang butuh konfigurasi JSON
CONFIG_BACKEND='{{ backend_config | to_json }}'
Contoh variabel upstream_servers
# group_vars/webservers.yml
upstream_servers:
  - server: 10.10.10.21
    weight: 3
  - server: 10.10.10.22
    weight: 2

The combine Filter

The combine filter merges two dictionaries into one. This is very useful when you want to apply "defaults" and then override them:

Menggabungkan dua dictionary
# Default konfigurasi
default_config:
  worker_processes: 4
  keepalive_timeout: 65
 
# Override khusus server
override_config:
  worker_processes: 8
 
# Hasil combine (recursive=true untuk menggabungkan nested dict)
final_config: {{ default_config | combine(override_config, recursive=true) }}

The final final_config result will be:

Hasil combine
final_config:
  worker_processes: 8
  keepalive_timeout: 65

Notice that worker_processes comes from override_config (the value 8 overrides 4), while keepalive_timeout stays from default_config. This technique is the foundation of the "default + override" pattern widely used to manage multi-environment configuration.

The regex_replace Filter

This filter uses Regular Expressions (regex) to search for and replace text patterns. The most common examples are cleaning a domain of the www subdomain, or extracting a specific part of a string:

# domain: www.example.com → example.com
server_name {{ server_name | regex_replace('^www\\.', '') }};

Tip

Remember the escaping rule in Jinja2: since the backslash \ itself is a special character, to write the regex \. in a template you must write \\. (with two backslashes). Forgetting to write the double backslash is one of the most common regex bugs in templates.

Common Templating Mistakes

To keep you from getting stuck in the field, here's a summary of common mistakes that most often appear when working with Jinja2 in Ansible:

1. Using {{ }} for logic, not values. Statements like if must use {% %}. Writing {{ if enable_https }} will produce an error.

2. Forgetting the default filter for optional variables. If a variable is undefined and used in a template without default, the playbook will fail with the error 'xxx' is undefined.

3. Wrongly escaping backslashes in regex. In Jinja2, write \\. for the regex \., or a syntax error will appear.

4. Inconsistent whitespace. Empty lines or excess spaces at the end of a template make the template module report changed every time, so the reload handler keeps getting triggered. Clean up trailing whitespace, or consider storing the rendered file as a reference.

5. Placing templates in the wrong location. The template module looks for src relative to the playbook (or role) directory. Moving a .j2 file without fixing the path is a very common cause of the could not locate file error.

To make sure the template doesn't error on any host, we can add an NGINX configuration validation task before reloading. This technique will also be useful for the error handling covered in episode 10:

Validasi hasil template sebelum reload
---
- name: Render template dengan validasi
  hosts: webservers
  become: true
  vars:
    server_name: blog.example.com
    nginx_port: 8080
    docroot: /var/www/blog
    enable_https: false
 
  tasks:
    - name: Render dan validasi konfigurasi NGINX
      ansible.builtin.template:
        src: templates/nginx-vhost.conf.j2
        dest: /etc/nginx/sites-available/blog.conf
        owner: root
        group: root
        mode: "0644"
        validate: nginx -t -c %s
      notify: Reload Nginx
 
  handlers:
    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

The validate parameter runs the nginx -t -c %s command against the rendered file before it's actually installed. If the template-rendered configuration is invalid, the playbook fails before a broken file replaces the running configuration. This is a highly recommended practice for critical configuration files.

Caution

Always use validate for critical configuration files like NGINX, Apache, and sshd. A broken configuration file can make a service impossible to start, and in the case of sshd, could make you lose remote access to the server entirely.

Conclusion

In episode 8, we explored one of Ansible's most powerful capabilities: Jinja2 templating. You now understand Jinja2's three basic syntaxes: the {{ }} expression, the {% %} statement (especially if), and the {# #} comment. We also practiced using the ansible.builtin.template module to render dynamic configuration files, from an NGINX virtual host template adapting to each server's IP and domain, to templates leveraging ansible facts like CPU count and hostname. Finally, we covered filters: default, lower, upper, to_json, to_yaml, combine, regex_replace, along with examples and common pitfalls.

Key takeaways:

  • One .j2 template can produce many different configuration files, simply by changing the input variables.
  • {{ }} to display values, {% %} for logic, {# #} for comments.
  • The template module is idempotent and can be combined with handlers from episode 6.
  • Templates have full access to variables and ansible facts, so never hardcode values obtainable from the system.
  • The default, to_json, to_yaml, combine, and regex_replace filters are your main weapons for manipulating data.
  • Use the validate parameter to protect services from incorrectly rendered configuration.

In episode 9, we'll cover Control Flow (Conditionals & Loops). We'll learn how to make playbooks that truly "think", from branching with when (plus and, or, not), modern looping with loop, to the until retry technique. Everything you learned today about variables, facts, and templates will be a very useful foundation in that episode. Keep your enthusiasm up!

Learn Ansible - Jinja2 Templating & Filters | Learn Ansible