NovuSpark
All articles
AnsibleNovember 14, 2025 · NovuSpark Team

Ansible Roles: Structuring Reusable Automation

This is the second post in our Ansible fundamentals series. Start with Ansible 101 if you're joining partway through.

The playbook from our last post worked well for one specific job — configuring web servers — defined entirely in one file. The moment a second playbook also needs "install and configure nginx," maybe with slightly different settings, the honest options are: copy those tasks into the new playbook and let them slowly diverge, or extract them into something reusable. A role is Ansible's answer for the second option.

What a role actually is

A role is a directory structure with a fixed, predictable layout — Ansible knows to look for specific files in specific places, so a role doesn't need to declare its own structure the way a playbook does.

roles/
  nginx/
    tasks/
      main.yml
    handlers/
      main.yml
    templates/
      nginx.conf.j2
    defaults/
      main.yml
    files/
      index.html
# roles/nginx/tasks/main.yml
---
- name: Install nginx
  ansible.builtin.apt:
    name: nginx
    state: present
    update_cache: true
 
- name: Deploy configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: Restart nginx
 
- name: Ensure nginx is running
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
# roles/nginx/handlers/main.yml
---
- name: Restart nginx
  ansible.builtin.service:
    name: nginx
    state: restarted

Two new concepts appear here that didn't exist in the single-file playbook version:

  • notify and handlers. A handler only runs if a task that notifies it actually reported changed. Here, nginx only gets restarted when its configuration actually changed — not on every single playbook run, which matters for anything where an unnecessary restart has a real cost (a brief service interruption, a load balancer health check flapping).
  • defaults/main.yml holds variables a role's caller is expected to be able to override — the role's equivalent of a Terraform module's variables with sensible defaults, covered in our Terraform variables post if you want the direct comparison.

Calling a role from a playbook

# site.yml
---
- name: Configure web servers
  hosts: web
  become: true
  roles:
    - nginx

That's the entire playbook now. Every detail of how to configure nginx lives inside the role; the playbook's job is reduced to deciding which roles apply to which hosts.

# site.yml — a more realistic multi-role example
---
- name: Configure web servers
  hosts: web
  become: true
  roles:
    - common
    - nginx
    - monitoring
 
- name: Configure database servers
  hosts: db
  become: true
  roles:
    - common
    - postgresql
    - monitoring
role: commonhosts: webcommonnginxmonitoringhosts: dbcommonpostgresqlmonitoring
Fig. 1 — roles compose: shared roles apply everywhere, group-specific roles apply only where relevant

Roles compose. common (base OS hardening, standard packages, an internal monitoring agent) applies to every host; nginx and postgresql are specific to what that group of hosts actually runs. This is the shape most real Ansible codebases converge on: a small number of focused, single-purpose roles, combined per host group in the playbook itself.

Making a role actually configurable

# roles/nginx/defaults/main.yml
---
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_client_max_body_size: "1m"
{# roles/nginx/templates/nginx.conf.j2 #}
worker_processes {{ nginx_worker_processes }};
 
events {
    worker_connections {{ nginx_worker_connections }};
}
 
http {
    client_max_body_size {{ nginx_client_max_body_size }};
    # ...
}

A caller who needs different values overrides them without touching the role itself:

- hosts: web
  become: true
  roles:
    - role: nginx
      vars:
        nginx_worker_connections: 4096
        nginx_client_max_body_size: "20m"

This is the same design principle we covered for Terraform modules: a good role's interface is its variables, not its internals. A caller who needs to reach inside roles/nginx/tasks/main.yml to get the behavior they want means the role's defaults and variables don't yet cover what people actually need from it.

Role dependencies

A role can declare that it depends on other roles, which Ansible then runs automatically before the dependent role's own tasks:

# roles/monitoring/meta/main.yml
---
dependencies:
  - role: common
    vars:
      install_base_agent: true

This means a playbook calling monitoring doesn't need to separately remember to also list common — Ansible resolves it automatically from the dependency declaration. Used sparingly, this is genuinely useful for roles that are never meaningfully correct on their own; overused, it can make it surprisingly hard to trace which roles actually run on a given host just by reading the playbook, so it's worth reserving for dependencies that are truly structural, not a substitute for explicitly listing roles in the playbook where the ordering matters for readability.

Where roles come from beyond your own project

ansible-galaxy is Ansible's equivalent of the Terraform Registry — a public catalog of community and vendor-maintained roles.

ansible-galaxy install geerlingguy.nginx
# requirements.yml — pinning roles the same way you'd pin a provider
roles:
  - name: geerlingguy.nginx
    version: "3.1.4"
  - name: geerlingguy.postgresql
    version: "2.3.0"
ansible-galaxy install -r requirements.yml

For genuinely standard, well-trodden configuration (a properly hardened PostgreSQL install, a standard Docker Engine setup), a well-maintained Galaxy role is usually a better starting point than writing the equivalent from scratch — the same argument we made for reaching into the Terraform Registry before writing a custom module. Reserve custom, in-house roles for what's genuinely specific to your own infrastructure and applications, and pin Galaxy role versions in requirements.yml for the same reason you'd pin a Terraform provider or module version — an unpinned dependency can change behavior underneath you with no corresponding change in your own history.

Handlers with multiple listeners

A single configuration change occasionally needs to trigger more than one downstream action — restarting a service and also clearing a cache, say. Rather than notifying two separate handlers individually from every task that might need both, listen lets several handlers share one logical trigger name:

# roles/nginx/handlers/main.yml
---
- name: Restart nginx
  ansible.builtin.service:
    name: nginx
    state: restarted
  listen: "nginx config changed"
 
- name: Clear nginx cache
  ansible.builtin.file:
    path: /var/cache/nginx
    state: absent
  listen: "nginx config changed"
- name: Deploy configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: "nginx config changed"

A single notify: "nginx config changed" now triggers both handlers, in the order they're defined — a cleaner pattern than a task listing multiple individual handler names by hand, and one that scales naturally as more handlers need to react to the same underlying event.

Testing a role's syntax before running it against real hosts

Before a role ever touches a real host, --syntax-check and --check (introduced in the first post in this series) catch two different classes of problem cheaply:

ansible-playbook site.yml --syntax-check

--syntax-check validates YAML structure and Ansible's own syntax — catching a malformed task, a missing colon, an incorrectly indented block — without connecting to any host at all, the fastest possible feedback loop before even attempting a dry run. Running this as a pre-commit check, or as a step in CI before the Molecule tests covered in the final post of this series, catches the cheapest class of mistakes before they cost a real test run's time.

Role variable precedence, revisited

We cover Ansible's full variable precedence order in depth in the next post in this series, but it's worth flagging here specifically: defaults/main.yml sits at the lowest precedence of any variable source, while vars/main.yml sits considerably higher — meaning a value in vars/main.yml silently overrides whatever a caller tried to set via roles: - role: nginx, vars: {...}. Using defaults/ for anything a caller is genuinely meant to override, and reserving vars/ for values that are truly internal to the role's own implementation, avoids a confusing situation where a caller's override appears to have no effect at all.

What to actually remember from this post

  • A role is a fixed directory structure Ansible recognizes automatically — tasks/, handlers/, templates/, defaults/, files/ — not an arbitrary convention you invent per project.
  • Handlers only run on actual change, via notify — this is what prevents an unnecessary service restart on every single run.
  • defaults/main.yml is a role's public interface — design it the way you'd design any reusable module's inputs.
  • Roles compose per host group — a small set of focused roles combined differently per group is the shape most real Ansible codebases converge on.
  • Reach for Ansible Galaxy for standard configuration, and pin its versions in requirements.yml; write custom roles for what's genuinely specific to your own systems.

Next in the series: Ansible Variables, Facts, and Templates, where we go deeper on exactly how Jinja2 templating and variable precedence actually work.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.