NovuSpark
All articles

November 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

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.

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

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.

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.
  • Reach for Ansible Galaxy for standard configuration; 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.