Ansible Jinja2 Templates and Roles
Quick answer
Structure reusable Ansible automation with roles, write dynamic configs with Jinja2 templating, and troubleshoot failing playbooks with check mode and verbosity flags.
- Jinja2 Templating
- Ansible Roles
- Using Roles in Playbooks
- Ansible Galaxy
- Advanced: include_tasks and import_tasks
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 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
| Delimiter | Purpose |
|---|---|
{{ expression }} | Output a value |
{% statement %} | Control flow (if, for) |
{# comment #} | Comment (not rendered) |
Basic variable output
{# 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:
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:
{{ 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:
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:
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
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
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
# Scaffold the directory structure
ansible-galaxy role init nginx
# Result: creates roles/nginx/ with all subdirectoriesRole: tasks/main.yml
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: trueRole: 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.
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
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: restartedRole: templates/nginx.conf.j2
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:
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
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
- 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.
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.yml1# 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:
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_enabledUse 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
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 internalsCheck mode — dry run
# Show what would change without applying anything
ansible-playbook deploy.yml --check
# Show file diffs (what content would change)
ansible-playbook deploy.yml --check --diffNot all modules support check mode (notably command and shell — they show as "skipped" or always report "changed"). For those, add:
- name: Init database (unsafe in check mode)
ansible.builtin.command: /opt/myapp/init-db.sh
check_mode: false # Always run, even in --checkStart at a specific task
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
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 - nginxDebug module
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 higherAssert module
Fail fast with a meaningful message if preconditions aren't met:
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
- Ansible Fundamentals — inventory, modules, and basic playbooks
- Linux Commands for Advanced Engineers — systemd and sysctl — the same config Ansible manages via templates
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.