Roles: Organizing Reusable Code

K
Kai··4 min read

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.ymllowest 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.