Part ofAnsible Automation·Step 2 of 2
DevOps & Platform

Ansible Jinja2 Templates and Roles

Intermediate60 min to complete16 min readJune 1, 2026Updated August 19, 2026

Quick answer

Structure reusable Ansible automation with roles, write dynamic configs with Jinja2 templating, and troubleshoot failing playbooks with check mode and verbosity flags.

intermediate · 60 min

Before you begin

  • Ansible Fundamentals — inventory, playbooks, core modules, variables
  • Basic Jinja2 or template engine experience helpful but not required
Ansible
Jinja2
Roles
Templates
Automation
DevOps

Ansible Jinja2 Templates and Roles

As playbooks grow beyond a dozen tasks, the single-file structure breaks down. The same tasks get copied across playbooks. Values are hardcoded. Testing becomes difficult.

Ansible solves this with roles — a directory structure that bundles tasks, variables, templates, and handlers into a reusable, testable unit. Combined with Jinja2 templates, roles generate dynamic configuration files from variables.


Jinja2 Templating

Ansible templates use Jinja2 — the same engine used by Flask, Django, and Salt. Template files conventionally have a .j2 extension.

Delimiters

DelimiterPurpose
{{ expression }}Output a value
{% statement %}Control flow (if, for)
{# comment #}Comment (not rendered)

Basic variable output

jinja2
{# templates/app.conf.j2 #}
[server]
hostname = {{ inventory_hostname }}
port = {{ app_port | default(8080) }}
environment = {{ env }}
workers = {{ ansible_processor_vcpus * 2 }}

Filters

Filters transform values. Apply with |.

String filters:

jinja2
1{{ app_name | upper }}          {# MYAPP #}
2{{ app_name | lower }}          {# myapp #}
3{{ app_name | capitalize }}     {# Myapp #}
4{{ path | basename }}           {# config.conf #}
5{{ path | dirname }}            {# /etc/app #}
6{{ text | trim }}               {# strip whitespace #}
7{{ text | replace('old', 'new') }}
8{{ name | regex_replace('^prefix_', '') }}

Default and existence:

jinja2
{{ var | default('fallback') }}         {# use fallback if var is undefined #}
{{ var | default(omit) }}               {# omit the key entirely if var is undefined #}
{{ var | mandatory }}                   {# fail with error if var is undefined #}
{{ var is defined }}                    {# boolean: true if var exists #}
{{ var is not defined }}

List and dict filters:

jinja2
1{{ list | join(', ') }}                 {# "a, b, c" #}
2{{ list | length }}                     {# 3 #}
3{{ list | sort }}
4{{ list | unique }}
5{{ list | select('match', '^web') | list }}    {# filter items matching regex #}
6{{ dict | dict2items }}                 {# convert dict to [{key:..., value:...}] #}
7{{ items | items2dict }}                {# reverse #}
8{{ dict1 | combine(dict2) }}            {# merge dicts (dict2 wins on conflict) #}

Type conversion:

jinja2
1{{ value | int }}
2{{ value | float }}
3{{ value | bool }}
4{{ value | string }}
5{{ value | to_json }}
6{{ value | to_yaml }}
7{{ json_string | from_json }}
8{{ yaml_string | from_yaml }}
9{{ secret | b64encode }}
10{{ encoded | b64decode }}

Conditionals in templates

jinja2
1[nginx]
2worker_processes = {{ nginx_workers | default(ansible_processor_vcpus) }};
3
4{% if ssl_enabled %}
5ssl_certificate     = /etc/ssl/certs/{{ domain }}.crt;
6ssl_certificate_key = /etc/ssl/private/{{ domain }}.key;
7{% else %}
8# SSL disabled
9{% endif %}
10
11{% if env == 'production' %}
12log_level = warn;
13{% elif env == 'staging' %}
14log_level = info;
15{% else %}
16log_level = debug;
17{% endif %}

Loops in templates

jinja2
1[upstream]
2{% for server in backend_servers %}
3server {{ server.host }}:{{ server.port | default(8080) }}{% if not loop.last %};{% endif %}
4
5{% endfor %}
6
7{# Produces: #}
8{# server app01:8080; #}
9{# server app02:8080; #}
10{# server app03:9000 #}

loop special variables: loop.index (1-based), loop.index0 (0-based), loop.first, loop.last, loop.length.


Ansible Roles

A role is a directory with a fixed structure:

roles/
└── nginx/
    ├── tasks/
    │   └── main.yml        # Task list
    ├── defaults/
    │   └── main.yml        # Default variable values (lowest priority)
    ├── vars/
    │   └── main.yml        # Role variables (high priority — hard to override)
    ├── handlers/
    │   └── main.yml        # Handlers
    ├── templates/
    │   └── nginx.conf.j2   # Jinja2 templates
    ├── files/
    │   └── logrotate.conf  # Static files (no templating)
    ├── meta/
    │   └── main.yml        # Dependencies on other roles
    └── README.md

Creating a role

bash
# Scaffold the directory structure
ansible-galaxy role init nginx

# Result: creates roles/nginx/ with all subdirectories

Role: tasks/main.yml

yaml
1---
2- name: Install Nginx
3  ansible.builtin.apt:
4    name: nginx
5    state: present
6    update_cache: "{{ nginx_update_cache }}"
7
8- name: Deploy nginx.conf
9  ansible.builtin.template:
10    src: nginx.conf.j2
11    dest: /etc/nginx/nginx.conf
12    owner: root
13    group: root
14    mode: "0644"
15    validate: "nginx -t -c %s"    # Validate config before deploying
16  notify: Reload Nginx
17
18- name: Enable and start Nginx
19  ansible.builtin.service:
20    name: nginx
21    state: started
22    enabled: true

Role: defaults/main.yml

Defaults are the lowest-priority variables. Any group_vars, host_vars, or play vars will override them. Use defaults for values the caller is expected to customize.

yaml
1---
2nginx_worker_processes: "auto"
3nginx_worker_connections: 1024
4nginx_keepalive_timeout: 65
5nginx_update_cache: true
6nginx_ssl_enabled: false
7nginx_backend_servers: []

Role: handlers/main.yml

yaml
1---
2- name: Reload Nginx
3  ansible.builtin.service:
4    name: nginx
5    state: reloaded
6
7- name: Restart Nginx
8  ansible.builtin.service:
9    name: nginx
10    state: restarted

Role: templates/nginx.conf.j2

jinja2
1user www-data;
2worker_processes {{ nginx_worker_processes }};
3pid /run/nginx.pid;
4
5events {
6    worker_connections {{ nginx_worker_connections }};
7}
8
9http {
10    keepalive_timeout {{ nginx_keepalive_timeout }};
11
12{% if nginx_backend_servers | length > 0 %}
13    upstream backend {
14{% for server in nginx_backend_servers %}
15        server {{ server }};
16{% endfor %}
17    }
18{% endif %}
19
20    server {
21        listen 80;
22        server_name {{ inventory_hostname }};
23
24{% if nginx_backend_servers | length > 0 %}
25        location / {
26            proxy_pass http://backend;
27        }
28{% else %}
29        root /var/www/html;
30{% endif %}
31    }
32}

Role: meta/main.yml

Declare dependencies on other roles:

yaml
1---
2dependencies:
3  - role: common          # Install common packages first
4  - role: firewall
5    vars:
6      firewall_allowed_ports:
7        - "80/tcp"
8        - "443/tcp"

Using Roles in Playbooks

yaml
1# site.yml
2---
3- name: Configure web servers
4  hosts: web
5  become: true
6  roles:
7    - common              # Run first, unconditionally
8    - role: nginx
9      vars:
10        nginx_worker_processes: 4
11        nginx_backend_servers:
12          - "10.0.0.1:3000"
13          - "10.0.0.2:3000"

Including roles dynamically

yaml
- name: Conditionally include a role
  ansible.builtin.include_role:
    name: monitoring
  when: monitoring_enabled | default(false)

Ansible Galaxy

Ansible Galaxy is the public role registry.

bash
1# Install a role from Galaxy
2ansible-galaxy role install geerlingguy.nginx
3
4# Install a specific version
5ansible-galaxy role install geerlingguy.nginx,3.2.0
6
7# List installed roles
8ansible-galaxy role list
9
10# Use a requirements file (for teams)
11ansible-galaxy role install -r requirements.yml
yaml
1# requirements.yml
2roles:
3  - name: geerlingguy.nginx
4    version: "3.2.0"
5  - name: geerlingguy.docker
6    version: "6.1.0"
7
8collections:
9  - name: community.postgresql
10    version: "3.0.0"

Advanced: include_tasks and import_tasks

Split a large tasks/main.yml into smaller files:

yaml
1# tasks/main.yml
2---
3- name: Include install tasks
4  ansible.builtin.import_tasks: install.yml      # Static — always included at parse time
5
6- name: Include SSL tasks
7  ansible.builtin.include_tasks: ssl.yml         # Dynamic — evaluated at runtime
8  when: nginx_ssl_enabled

Use import_tasks when the include is unconditional (better for --check mode). Use include_tasks when you need when: or loop: on the include itself.


Troubleshooting Playbooks

Verbosity levels

bash
ansible-playbook deploy.yml -v      # Show task results
ansible-playbook deploy.yml -vv     # Show task and connection details
ansible-playbook deploy.yml -vvv    # Show SSH commands
ansible-playbook deploy.yml -vvvv   # Show connection plugin internals

Check mode — dry run

bash
# Show what would change without applying anything
ansible-playbook deploy.yml --check

# Show file diffs (what content would change)
ansible-playbook deploy.yml --check --diff

Not all modules support check mode (notably command and shell — they show as "skipped" or always report "changed"). For those, add:

yaml
- name: Init database (unsafe in check mode)
  ansible.builtin.command: /opt/myapp/init-db.sh
  check_mode: false    # Always run, even in --check

Start at a specific task

bash
1# Skip tasks until this one
2ansible-playbook deploy.yml --start-at-task "Deploy nginx.conf"
3
4# Only run tasks with these tags
5ansible-playbook deploy.yml --tags "nginx,ssl"
6
7# Skip tagged tasks
8ansible-playbook deploy.yml --skip-tags "install"

Tagging tasks

yaml
1- name: Install packages
2  ansible.builtin.apt:
3    name: nginx
4    state: present
5  tags:
6    - install
7    - nginx
8
9- name: Configure Nginx
10  ansible.builtin.template:
11    src: nginx.conf.j2
12    dest: /etc/nginx/nginx.conf
13  tags:
14    - configure
15    - nginx

Debug module

yaml
1- name: Print all variables for this host
2  ansible.builtin.debug:
3    var: hostvars[inventory_hostname]
4
5- name: Print a specific variable
6  ansible.builtin.debug:
7    msg: "The value is: {{ my_var }}"
8    verbosity: 2    # Only show at -vv or higher

Assert module

Fail fast with a meaningful message if preconditions aren't met:

yaml
1- name: Validate required variables
2  ansible.builtin.assert:
3    that:
4      - app_version is defined
5      - app_version | length > 0
6      - env in ['dev', 'staging', 'production']
7    fail_msg: "app_version and env must be set. env must be dev/staging/production."
8    success_msg: "Variables look good."

Frequently Asked Questions

When should I split tasks into a role?

Once the same tasks are needed by more than one playbook, or a playbook grows past what you can read in one sitting. A role gives a conventional directory layout others recognise. A role wrapping three tasks used once is indirection without benefit.

What is the difference between include_tasks and import_tasks?

import_tasks is processed when the playbook is parsed, so conditionals apply to every task inside it and tags work as you expect. include_tasks is resolved during the run, so it can use variables not known until then, at the cost of tags and --list-tasks behaving less predictably. Import by default; include when you need runtime resolution.

Why does my template render an empty value?

The variable is undefined at that point, and Jinja2 renders undefined as empty rather than failing. Set undefined to be strict so a missing variable is an error, or provide an explicit default. Silent empty values are how a config file ends up syntactically valid and semantically wrong.

How do I debug variable precedence?

Print the value at the point it is used rather than reasoning about the precedence table. Ansible has many levels — role defaults through extra vars — and the practical answer is almost always that something later in the chain is overriding what you set. --extra-vars wins over nearly everything.

What's Next

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.