Steven's Knowledge

Roles & Templates

Package reusable logic into roles, render configs with Jinja2 templates, and trigger restarts with handlers

Roles & Templates

Once a playbook has more than ~20 tasks, you'll want to break it apart. Roles are the unit of reuse — directories that bundle tasks, variables, files, templates, and handlers under a single name.

Anatomy of a Role

roles/nginx/
├── defaults/
│   └── main.yml         # default variable values (lowest precedence)
├── vars/
│   └── main.yml         # role variables (higher precedence)
├── tasks/
│   └── main.yml         # tasks (the role's entrypoint)
├── handlers/
│   └── main.yml         # handlers (referenced by `notify`)
├── files/
│   └── htpasswd         # static files for `copy:`
├── templates/
│   └── nginx.conf.j2    # Jinja2 templates for `template:`
├── meta/
│   └── main.yml         # role metadata + dependencies
└── README.md

Ansible auto-loads the right file from the right directory — you never write absolute paths inside a role.

A Complete Role

Defaults

Sensible defaults the caller can override:

# roles/nginx/defaults/main.yml
---
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_keepalive_timeout: 65
nginx_client_max_body_size: 10m

nginx_sites: []                       # list of vhosts, supplied by caller

Tasks

The role's actual work:

# roles/nginx/tasks/main.yml
---
- name: Install nginx
  apt:
    name: nginx
    state: present
    update_cache: true

- name: Push main nginx.conf
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    mode: '0644'
  notify: restart nginx

- name: Push vhost configs
  template:
    src: vhost.conf.j2
    dest: "/etc/nginx/sites-available/{{ item.name }}.conf"
    owner: root
    mode: '0644'
  loop: "{{ nginx_sites }}"
  notify: reload nginx

- name: Enable vhosts
  file:
    src: "/etc/nginx/sites-available/{{ item.name }}.conf"
    dest: "/etc/nginx/sites-enabled/{{ item.name }}.conf"
    state: link
  loop: "{{ nginx_sites }}"
  notify: reload nginx

- name: Ensure nginx is running
  service:
    name: nginx
    state: started
    enabled: true

Handlers

Triggered by the notify: lines above:

# roles/nginx/handlers/main.yml
---
- name: restart nginx
  service:
    name: nginx
    state: restarted

- name: reload nginx
  service:
    name: nginx
    state: reloaded

Templates

Jinja2 templates can interpolate variables and facts:

# roles/nginx/templates/nginx.conf.j2
user www-data;
worker_processes {{ nginx_worker_processes }};

events {
    worker_connections {{ nginx_worker_connections }};
}

http {
    sendfile on;
    keepalive_timeout {{ nginx_keepalive_timeout }};
    client_max_body_size {{ nginx_client_max_body_size }};

    include /etc/nginx/sites-enabled/*.conf;
}

And use loops, conditionals, and host introspection:

# roles/nginx/templates/vhost.conf.j2
upstream {{ item.name }}_backend {
{% for host in groups[item.backend_group] %}
    server {{ hostvars[host]['ansible_host'] }}:{{ item.backend_port }};
{% endfor %}
}

server {
    listen 80;
    server_name {{ item.server_name }};

{% if item.tls_enabled | default(false) %}
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name {{ item.server_name }};
    ssl_certificate     /etc/letsencrypt/live/{{ item.server_name }}/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/{{ item.server_name }}/privkey.pem;
{% endif %}

    location / {
        proxy_pass http://{{ item.name }}_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
    }
}

Using a Role

In a playbook, list roles to apply:

# playbooks/site.yml
---
- hosts: webservers
  become: true

  roles:
    - common
    - role: nginx
      vars:
        nginx_worker_processes: 4
        nginx_sites:
          - name: api
            server_name: api.example.com
            backend_group: webservers
            backend_port: 3000
            tls_enabled: true
    - app

Plays run roles top-to-bottom. Order matters when there are dependencies (e.g., common sets up the user nginx will run as).

Role Dependencies

If a role always needs another role, declare it in meta/main.yml:

# roles/app/meta/main.yml
---
dependencies:
  - role: common
  - role: docker

Dependencies run before the role's own tasks. Use sparingly — explicit ordering in the playbook is usually clearer than implicit dependencies.

Where to Get Roles

You don't have to write every role. Ansible Galaxy hosts community roles:

ansible-galaxy role install geerlingguy.docker
ansible-galaxy collection install community.postgresql
# requirements.yml — version-locked dependencies
---
roles:
  - src: geerlingguy.docker
    version: 7.0.0

collections:
  - name: community.postgresql
    version: 3.4.0
ansible-galaxy install -r requirements.yml

Pin versions. Without version:, you get whatever's latest on Galaxy — and a "no changes" PR might silently pull in a breaking upgrade. Treat role and collection versions like any other dependency.

When NOT to Make a Role

A role for "install one package" is over-engineering. Heuristics:

  • Used in one playbook, never touched after? Inline tasks.
  • Re-used across plays, or more than a handful of tasks? Role.
  • Shared across teams or projects? Role with version, README, and example playbook.

What's Next

You can package reusable logic into roles with templates and handlers. The remaining patterns are the ones you need for production runs — secrets, dynamic inventories, conditionals, rolling deploys → Advanced Patterns.

On this page