Roles: Organizing Reusable Code
A long playbook with dozens of tasks is hard to read, hard to reuse, hard to share. A role is Ansible's answer: package everything needed to do one job (tasks, handlers, templates, files, variables) into a unit with a standard directory structure, reusable across playbooks and projects. This is the step from "writing playbooks" to "designing automation".
Why you need roles
Imagine you configure a web server, database, monitoring... in one giant site.yml. Problems:
- Hard to read: hundreds of mixed-up lines.
- Hard to reuse: want the "web server" part for another project? Copy-paste.
- Hard to share: no clean way to package it for others.
A role solves all three: gather the "web server" logic into a webserver role, then every playbook just calls that role. Package once, use everywhere.
Create a role: ansible-galaxy init
Ansible has a built-in command to scaffold a role with the standard structure:
ansible-galaxy init roles/webserver
- Role roles/webserver was created successfully
It builds the directory tree:
roles/webserver/
├── defaults/ # DEFAULT variables (lowest precedence — Article 6) → main.yml
├── vars/ # role variables (higher precedence than defaults)
├── tasks/ # main tasks → main.yml (entry point)
├── handlers/ # handlers (notify) → main.yml
├── templates/ # Jinja2 files (.j2)
├── files/ # static files (copied as-is)
├── meta/ # metadata + dependencies on other roles
└── tests/ # test scaffold
Each directory has a clear role, and Ansible finds the right place automatically: when a role runs, it reads tasks/main.yml as the entry point, loads defaults/main.yml and vars/main.yml, and finds templates in templates/. You don't declare paths — that's the power of convention.
Fill in the role
roles/webserver/tasks/main.yml (entry point):
---
- name: Install nginx
ansible.builtin.dnf: { name: nginx, state: present }
- name: Enable nginx
ansible.builtin.service: { name: nginx, state: started, enabled: true }
- name: Index page from template
ansible.builtin.template:
src: index.html.j2 # found automatically in roles/webserver/templates/
dest: /usr/share/nginx/html/index.html
notify: Restart nginx # handler in roles/webserver/handlers/
roles/webserver/defaults/main.yml (default values, easily overridden):
site_name: "Default Site"
roles/webserver/templates/index.html.j2:
<h1>{{ site_name }} — webserver role</h1>
roles/webserver/handlers/main.yml:
---
- name: Restart nginx
ansible.builtin.service: { name: nginx, state: restarted }
Notice src: index.html.j2 needs no full path — Ansible finds it in the role's templates/. Likewise the Restart nginx handler is found in the role's handlers/.
Use a role in a playbook
The playbook is now very compact — it just calls the role and passes variables:
---
- hosts: web
become: true
roles:
- role: webserver
vars:
site_name: "KKloud via Role"
Run:
TASK [webserver : Install nginx] **********
ok: [lab]
TASK [webserver : Index page from template]
changed: [lab]
RUNNING HANDLER [webserver : Restart nginx] *
changed: [lab]
PLAY RECAP ********************************
lab : ok=5 changed=2 failed=0
curl returns <h1>KKloud via Role — webserver role</h1>. Notice tasks show as webserver : <task name> — Ansible prepends the role name, so the log shows which task belongs to which role. And the site_name we passed (KKloud via Role) overrides the default in defaults/ (correct precedence, Article 6).
defaults vs vars: where to put variables in a role
A role has two places for variables, differing in precedence (Article 6):
defaults/main.yml— lowest precedence. Put default values here so role users can easily override them (via group_vars, or vars when calling the role). This is where a role's "parameters" go.vars/main.yml— higher precedence, harder to override. Put internal role values that shouldn't change here.
Rule: user-customizable parameters → defaults/; internal constants → vars/.
Ways to call a role
# 1. roles: declaration (runs before the play's tasks)
roles:
- webserver
- role: database
vars: { db_name: app }
# 2. include_role (dynamic, in tasks — can go inside when/loop)
- ansible.builtin.include_role:
name: webserver
# 3. import_role (static, loaded at parse time)
- ansible.builtin.import_role:
name: webserver
roles: is the most common way. include_role/import_role are more flexible (calling a role conditionally, in a loop). The include vs import difference: import loads statically when the playbook is parsed; include loads dynamically at run time (letting when/loop decide whether to call it).
Dependencies between roles
meta/main.yml declares that this role depends on another (which runs first automatically):
dependencies:
- role: common # run "common" before "webserver"
Useful: e.g. every app role depends on a common role (basic config, users, firewall). But don't overuse it — implicit dependencies are hard to track; many people prefer calling explicitly in the playbook.
Galaxy: share and reuse others' roles
ansible-galaxy doesn't just create roles — it also downloads roles/collections others have written from Ansible Galaxy (the community repository). For example, instead of writing your own role to install Docker, use a ready-made one. This is the bridge to Article 9 (Collections) — the modern way to package and distribute.
🧹 Cleanup
The sample role is in the nghiadaulau/ansible-series repo, directory 08-roles/roles/webserver. The EC2 lab stays up through Article 16.
Wrap-up
A role packages tasks, handlers, templates, files, and variables into a reusable unit with a standard directory structure (tasks/, handlers/, templates/, defaults/, vars/, meta/). Create it with ansible-galaxy init; Ansible finds the right file by convention. Put parameters in defaults/ (easily overridden), constants in vars/. Use a role via roles: (or include_role/import_role), passing variables when calling. Roles are the foundation of professional Ansible code organization.
Roles help organize within one project. Article 9 goes up a level: collections — the modern packaging and distribution unit that bundles many roles, modules, and plugins together, with FQCN.