Sign in
← Ansible
15 min · medium · Lesson 7 of 12

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
myrole/ role root tasks/main.yml entry point - tasks handlers/main.yml handlers (notify) templates/ Jinja2 .j2 - template module files/ static - copy module vars/main.yml high priority defaults/main.yml low priority meta/main.yml dependencies, Galaxy main.yml - autoload read without include_tasks by convention (no main.yml)

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.ymlvars/main.yml
PriorityLowestHigh
Overridden from outsideEasilyOnly by -e and a few others
PurposeConfigurable parametersInternal 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

What you'll need for the tasks

The next lesson covers variables, facts and the full precedence ladder.

Done reading?

Mark the lesson as read to move your track progress forward.

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.

Get access for $49

Tell the agent: use MCP izi, give me lesson ansible-basics-07-roles