Roles and folder structure
tasks/handlers/templates/files/vars/defaults/meta, autoloading main.yml, ansible-galaxy init, defaults vs vars.
Why roles
A playbook of 50 tasks is already painful to read, one of 200 is impossible. And when you need to take the same logic into another project, you want to copy a piece, not the whole file.
A role solves this with a standard folder structure, where each folder is responsible for its own kind of content. Ansible knows this structure and picks up the files on its own, nothing needs to be wired up by hand.
The role tree
roles/
myrole/
tasks/
main.yml
handlers/
main.yml
templates/
nginx.conf.j2
files/
index.html
vars/
main.yml
defaults/
main.yml
meta/
main.yml
tests/ # optional
inventory
test.yml
library/ # optional, custom modules
ansible-galaxy init myrole creates the skeleton with one command: the
tasks, handlers, templates, files, vars, defaults, meta,
tests folders and a README.md file. It doesn't create the library/
folder; that's added by hand once the role gets its own modules.
ansible-galaxy init myrole
- Role myrole was created successfully
What lives in each folder
tasks/main.yml — the role's entry point, its main task list. Ansible loads this file on its own once the role is included.
handlers/main.yml — the role's handlers. They're called through
notify from tasks. A handler runs once, even if it was notified several
times, and not right away but at the end of the play's section. For a role
pulled in through roles:, that's the moment after all roles and tasks
from tasks:, before post_tasks.
templates/ — Jinja2 templates with a .j2 extension. The template
module in a role's task looks for src right here, so you just write
src: nginx.conf.j2. A template can take variables, loops and conditions.
files/ — static files with no templating. The copy module pulls them
from here: binaries, certificates, anything that doesn't need rendering.
vars/main.yml — role variables with high priority. group_vars and
host_vars can't override them, but -e (extra vars) still wins. This is
where you put internal constants of the role that shouldn't change from
outside.
defaults/main.yml — default variables with the lowest priority
among variables. They're easy to override: through the inventory,
group_vars, host_vars, the play's vars:, -e. This is the right place
for anything the role's user should be able to configure.
meta/main.yml — metadata and dependencies. The dependencies field
declares other roles that run before this one. It also holds the fields for
Galaxy: author, license, supported OSes.
defaults and vars: what's the difference
This is a common interview question.
defaults/main.yml | vars/main.yml | |
|---|---|---|
| Priority | Lowest | High |
| Overridden from outside | Easily | Only by -e and a few others |
| Purpose | Configurable parameters | Internal constants |
The rule is simple: anything the role's user should be able to change
goes in defaults. Internal constants that shouldn't drift go in vars.
Example: an application port goes in defaults, since ports differ across
environments. The name of a system user the role creates and relies on in
several tasks goes in vars.
Let's check this on a role where one variable lives in defaults, another
in vars, and host_vars/h1.yml sets both:
# roles/r/defaults/main.yml
p_def: from_defaults
# roles/r/vars/main.yml
p_var: from_role_vars
# host_vars/h1.yml
p_def: from_host_vars
p_var: from_host_vars
TASK [r : show vars] ***********************************************************
ok: [h1] => {
"msg": "p_def=from_host_vars p_var=from_role_vars"
}
host_vars overrode defaults, but the role's vars held. With
-e p_var=from_e, extra vars wins instead. The full precedence ladder is
coming in the next lesson.
Autoloading
Ansible reads main.yml on its own from tasks/, handlers/, vars/,
defaults/ and meta/ once the role is included. You don't need to write
include_tasks: tasks/main.yml. Create the file at the right path, and it
just works.
Pay attention to the name: it's specifically main.yml (or main.yaml,
main) that gets read. Ansible won't pick up handlers/restart.yml on its
own, and a task with notify will fail:
[ERROR]: The requested handler 'role handler' was not found in either the main handlers list nor in the listening handlers list
From tasks/main.yml you can include extra files from the same folder:
# tasks/main.yml
- name: install packages
import_tasks: install.yml
- name: configure service
import_tasks: configure.yml
Including a role in a playbook
The main way is the roles: block in the play:
- name: Configure web server
hosts: webservers
roles:
- common
- role: nginx
vars:
nginx_port: 8080
- myrole
Execution order in the play: pre_tasks → roles from roles: → tasks →
post_tasks. Handlers called in roles and in tasks fire after tasks,
before post_tasks.
In role r, a task calls a handler, and every section of the play has a
debug with its name. The run's output (the TASK and msg lines):
TASK [debug] *******************************************************************
"msg": "PRE"
TASK [r : show vars] ***********************************************************
"msg": "p_def=from_host_vars p_var=from_role_vars"
TASK [r : notify in role] ******************************************************
TASK [debug] *******************************************************************
"msg": "TASKS section"
RUNNING HANDLER [r : role handler] *********************************************
"msg": "HANDLER RAN"
TASK [debug] *******************************************************************
"msg": "POST"
If you need a role in the middle of tasks or you need to include it
conditionally, use include_role or import_role:
- name: Setup app
hosts: all
tasks:
- name: prepare os
import_tasks: prepare.yml
- name: apply nginx role
import_role:
name: nginx
vars:
nginx_port: 443
- name: post-setup checks
import_tasks: checks.yml
import_role is a static import: the role is expanded before the run
starts, like import_tasks. include_role is dynamic: the role is
included during execution and supports when and loop.
The difference shows up with handlers. If a task before include_role
calls that role's handler, Ansible doesn't know it yet and fails with the
same The requested handler ... was not found error. With import_role,
the role's handlers are known from the very start, and that kind of
notify works.
Where roles live
Ansible looks for roles in the roles/ folder next to the playbook and in
the paths from roles_path. Without configuration that's
~/.ansible/roles, /usr/share/ansible/roles and /etc/ansible/roles.
Set your own path in ansible.cfg:
[defaults]
roles_path = ./roles:~/.ansible/roles
Ansible Galaxy is a public registry of ready-made roles. Installing a role from Galaxy:
ansible-galaxy install geerlingguy.nginx
The role lands in the first writable path from roles_path (without
configuration that's ~/.ansible/roles). A different folder can be set
with the -p flag.
It's convenient to pin a project's dependencies in requirements.yml with
exact versions:
roles:
- name: geerlingguy.nginx
version: "3.3.1"
- name: geerlingguy.postgresql
version: "4.2.0"
Install everything at once:
ansible-galaxy install -r requirements.yml
Common mistakes
- A configurable parameter was put in
vars/main.yml. The value fromgroup_varsorhost_varsis silently ignored, there's no error, the config just has the old number. - A file isn't named
main.yml(handlers/restart.yml,tasks/setup.yml). Ansible doesn't read it: the role's tasks don't run, andnotifyfails withhandler ... was not found. - A template was put in
files/. Thetemplatemodule looks forsrcin the role'stemplates/(and nearby), not infiles/, and fails withCould not find or access. The error text lists every place it looked. - The playbook failed after a task that had already notified a handler. On
the next run, the task returns
ok, and the handler doesn't fire: the notification was lost along with the failed run. Restart the service by hand, or run the play with--force-handlersso handlers fire even on failure. - A role from Galaxy was installed without a version. Six months later
ansible-galaxy installpulls a different one, and the run behaves differently.
What you'll need for the tasks
ansible-galaxy init --init-path roles <name>for a role skeleton in theroles/folder;- the
tasks/,handlers/,templates/,defaults/,vars/folders and the rule about themain.ymlname; - the difference between
defaultsandvars: whathost_varsandgroup_varsoverride; - including a role through
roles:and checking that a value made it through:ansible -m debug -a var=<name>.
The next lesson covers variables, facts and the full precedence ladder.
Interview-style questions
Interview questions on this topic: 1
Included with practice access, $49 one-time. Get access
Answer out loud or in writing, then reveal the answer and honestly mark whether you knew it.
Done reading?
Mark the lesson as read to move your track progress forward.
Lesson marked as done.
Go through it with an AI agent
The agent explains anything unclear in plain words, gives you 2 labs for this lesson and drills you on interview questions. $49 one-time, lifetime access.
Tell the agent: use MCP izi, give me lesson ansible-basics-07-roles
Progress saved in this browser
Clear your history or open this on your phone, and the marks will be gone. Leave your email and they'll move to your account. No password needed.
Done. moved to your account. You can see your progress on any device. My progress