A playbook with 200 inline tasks works — until you want to deploy MySQL on a second project, or share the install logic with another team. A role packages a self-contained chunk of automation (tasks + handlers + templates + defaults + documentation) into a directory you can drop into any playbook with one line.
Every role follows the same skeleton — Ansible auto-discovers the right files because of the directory names. You don't need to wire anything together manually.
| Directory | Auto-loaded? | Purpose |
|---|---|---|
tasks/main.yml | Yes — entry point | The list of tasks the role runs |
handlers/main.yml | Yes | Handlers callable from this role's tasks |
defaults/main.yml | Yes | LOWEST-precedence variables |
vars/main.yml | Yes | Role-internal "constants" |
templates/ | Auto-resolved by template: | Jinja2 files referenced as src: my.cnf.j2 |
files/ | Auto-resolved by copy: | Static files referenced as src: repo.gpg |
meta/main.yml | Yes | Role metadata + dependencies |
tests/ | No (manual) | Molecule tests for the role |
library/ | Yes | Custom Python modules bundled with the role |
filter_plugins/ | Yes | Custom Jinja2 filters |
# create the standard skeleton
cd ./roles
ansible-galaxy init mysql
# what it generates
ls mysql/
# defaults files handlers meta README.md tasks templates tests vars
# verify
cat mysql/tasks/main.yml
# ---
# # tasks file for mysql
Three syntaxes to invoke a role — they're equivalent for simple cases:
---
# 1. Apply at the play level — runs roles BEFORE explicit tasks
- hosts: databases
become: true
roles:
- common
- mysql # short form
- role: monitoring # long form
vars:
metrics_port: 9100
tasks:
- name: Open metrics port
ansible.posix.firewalld:
port: 9100/tcp
state: enabled
---
# 2. Mid-task using import_role (static — flattened at parse time)
- hosts: databases
tasks:
- name: Apply common role
ansible.builtin.import_role:
name: common
# 3. Mid-task using include_role (dynamic — applied at runtime)
- hosts: databases
tasks:
- name: Apply OS-specific role
ansible.builtin.include_role:
name: "os-{{{{ ansible_distribution | lower }}}}"
Every role can declare its own dependencies. Ansible resolves the chain and runs dependencies before the role itself. Useful when role A genuinely won't work without role B having configured something first.
---
galaxy_info:
author: deploy-team
description: Install and configure MySQL 8 on OEL 8
license: MIT
min_ansible_version: "2.14"
platforms:
- name: EL
versions:
- "8"
galaxy_tags:
- database
- mysql
dependencies:
# roles below run BEFORE this one
- role: common
- role: firewall
vars:
firewall_open_ports:
- 3306
- role: epel
when: ansible_os_family == "RedHat"
app always wants nginx, that's a real dependency. If role app sometimes wants nginx, sometimes apache — let the calling playbook compose them. Don't bury the choice inside meta.When a play says roles: [mysql], Ansible searches the following paths in order
and uses the first match it finds:
./roles/<name>/— relative to the playbook- Directories listed in
roles_pathinansible.cfg ~/.ansible/roles/— your user's Galaxy install dir/etc/ansible/roles/— system-wide install dir
[defaults]
# colon-separated, like PATH
# Ansible will search each in order
roles_path = ./roles:./shared-roles:~/.ansible/roles
# Show every role currently visible to ansible-galaxy
ansible-galaxy role list
# Run with -vv to see which directory Ansible loaded for each role
ansible-playbook site.yml -vv 2>&1 | grep "ROLE\|Loading"
# → Loading role 'mysql' from /home/deploy/work/proj/roles/mysql
# Show the dependency tree of a role
ansible-galaxy role list --include-dependencies geerlingguy.mysql
tasks/main.yml, settings at defaults/main.yml, templates at templates/. No surprises.