The problem
The playbook from the last lessons installs nginx, templates its config, notifies a handler, sets variables. The database team wants the same nginx setup on their admin host; another project wants it with a different port. Copying fifty lines of tasks between playbooks is how fleets drift apart. Roles package tasks, handlers, templates, files and default variables into one reusable directory with a fixed layout, and collections package modules and roles for others to install.
What you need to know already: the handlers and templates lessons, and the variables lesson (precedence: role defaults are the weakest level).
A role is a directory
roles/nginx/
tasks/main.yml the role's tasks (the entry point)
handlers/main.yml its handlers
defaults/main.yml default variables - meant to be overridden
vars/main.yml role variables - hard to override (high precedence)
templates/ .j2 files, found by template: src: without a path
files/ files for copy: src:
meta/main.yml metadata and dependencies on other roles
tests/, README.md
Only tasks/main.yml is needed; Ansible loads whatever else exists. ansible-galaxy role init writes the whole skeleton:
$ cd ~/oncall-lab/labs/1a-ansible/try/roles
$ ansible-galaxy role init roles/motd
- Role roles/motd was created successfully
$ find roles/motd -type f | sort
roles/motd/README.md
roles/motd/defaults/main.yml
roles/motd/handlers/main.yml
roles/motd/meta/main.yml
roles/motd/tasks/main.yml
roles/motd/tests/inventory
roles/motd/tests/test.yml
roles/motd/vars/main.yml
The lab project has a finished role next to it:
$ cat roles/nginx/defaults/main.yml roles/nginx/tasks/main.yml
# the role's knobs: override them in group_vars, host_vars or the play
nginx_port: 80
nginx_server_name: "{{ inventory_hostname }}"
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Site config
ansible.builtin.template:
src: site.conf.j2
dest: /etc/nginx/sites-available/default
mode: "0644"
notify: Reload nginx
- name: nginx runs and starts at boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
Inside a role, template: src: site.conf.j2 means roles/nginx/templates/site.conf.j2, and notify: Reload nginx finds roles/nginx/handlers/main.yml. The role carries everything it needs.
defaults vs vars
defaults/main.yml holds the role's knobs: the lowest precedence of all, so any group_vars, host_vars or play var overrides them. Put every value a user might want to change here, with a sensible default. vars/main.yml is for internal constants (package names per OS, paths) that users should not touch - it beats inventory and play vars, so putting user-facing settings there is a classic way to make them impossible to override.
A convention ansible-lint enforces: prefix a role's variables with the role name (nginx_port, not port), because all variables share one namespace.
Using roles
The roles: section of a play runs roles before the play's own tasks::
- name: Web servers
hosts: web
become: true
roles:
- nginx # roles/nginx next to the playbook
- role: motd
vars:
motd_text: "managed by the platform team"
$ cat site.yml
- name: Web servers
hosts: web
become: true
roles:
- nginx
$ ansible-playbook site.yml
PLAY [Web servers] *************************************************************
TASK [Gathering Facts] *********************************************************
ok: [web-1]
ok: [web-2]
TASK [nginx : Install nginx] ***************************************************
changed: [web-1]
changed: [web-2]
TASK [nginx : Site config] *****************************************************
changed: [web-1]
changed: [web-2]
TASK [nginx : nginx runs and starts at boot] ***********************************
ok: [web-1]
ok: [web-2]
RUNNING HANDLER [nginx : Reload nginx] *****************************************
changed: [web-1]
changed: [web-2]
PLAY RECAP *********************************************************************
web-1 : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web-2 : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Task names show the role: TASK [nginx : Install nginx]. Ansible looks for a role in roles/ next to the playbook, then the roles_path of ansible.cfg (default ~/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles), then next to the playbook itself. A typo prints every path it tried:
$ ansible-playbook typo.yml
[ERROR]: the role 'ngnix' was not found in /home/learner/oncall-lab/labs/1a-ansible/try/roles/roles:/home/learner/.ansible/roles:/usr/share/ansible/roles:/etc/ansible/roles:/home/learner/oncall-lab/labs/1a-ansible/try/roles
Origin: typo.yml:5:7
3 become: true
4 roles:
5 - ngnix
^ column 7
import vs include
Two ways to pull in more tasks or a role in the middle of a task list:
- ansible.builtin.import_tasks: hardening.yml # STATIC
- ansible.builtin.include_tasks: "{{ ansible_facts['os_family'] }}.yml" # DYNAMIC
- ansible.builtin.import_role: { name: nginx }
- ansible.builtin.include_role: { name: nginx }
- import_ is static: the tasks are copied in when the playbook is parsed. Tags and
when:on the import apply to every imported task, and--list-tasksshows them. The file name cannot depend on a variable that is only known at run time. - include_ is dynamic: decided when the run reaches it. The file name can be a variable, it can be looped, and
when:decides whether to include at all. The output shows a lineincluded: .../Debian.yml for web-1, web-2, and--list-taskscannot see inside.
Rule of thumb: import by default; include when the file depends on a fact or a loop.
Dependencies
meta/main.yml can list roles that must run first:
dependencies:
- role: common
Handy, and easy to overdo: a long dependency chain makes "what does this play actually run?" hard to answer. Many teams prefer listing roles explicitly in the play.
Collections and Galaxy
A collection is the unit of distribution: modules, roles, plugins under a namespace, like community.general or ansible.posix. You call its content by FQCN: ansible.posix.authorized_key, community.general.ufw. The ansible package ships about 90 of them:
$ ansible-galaxy collection list ansible.posix
# /usr/lib/python3/dist-packages/ansible_collections
Collection Version
---------------------------------------- -------
ansible.posix 2.1.0
$ ansible-doc -l ansible.posix | head -5
ansible.posix.authorized_key Adds or removes an SSH authorized key
ansible.posix.sysctl Manage entries in sysctl.conf
Ansible Galaxy (galaxy.ansible.com) is the public index. A project pins what it needs in requirements.yml and installs it with one command, so every engineer and the pipeline get the same versions:
# requirements.yml
collections:
- name: community.general
version: ">=12.0.0"
roles:
- name: nginx
src: https://git.lab/platform/ansible-role-nginx.git
version: v1.2.0
ansible-galaxy collection install -r requirements.yml into ~/.ansible/collections
ansible-galaxy role install -r requirements.yml into ~/.ansible/roles
(This box has no internet: collections come from a lab mirror, roles from git.lab - simulator.)
A project layout that scales
platform-ansible/
ansible.cfg
requirements.yml
inventories/
prod/hosts.ini one inventory per environment
prod/group_vars/all.yml
prod/group_vars/web.yml
staging/hosts.ini
staging/group_vars/...
site.yml imports the other playbooks
web.yml db.yml
roles/
nginx/ users/ common/
ansible-playbook -i inventories/staging/hosts.ini site.yml and the same playbooks run against staging with staging's variables; -i inventories/prod/hosts.ini for prod. The difference between environments lives in their group_vars, not in copies of the playbooks.
What you can now do
- Lay out and create a role (
ansible-galaxy role init), and know what each directory is for - especially defaults vs vars. - Use roles from a play, pass them variables, and read the role-prefixed task names.
- Choose import (static) vs include (dynamic).
- Install pinned collections and roles from
requirements.yml, and call collection modules by FQCN. - Lay out a multi-environment project with one inventory directory per environment.