Chapter 8: Roles & Reuse
Topic 45

Role Anatomy

RolesStructure

A role is a packaged unit of tasks, handlers, templates, files, and defaults that lives in a fixed directory layout Ansible knows how to load. Once the Larkspur web playbook grows past a dozen tasks with three handlers and four templates, that flat playbook is exactly the thing roles exist to replace. The same content moves into roles/larkspur_web/ under named subdirectories, and Ansible auto-loads each one by convention — you never write a path to tasks/main.yml, you just call the role by name.

The payoff is that the role becomes addressable. Instead of a pile of file paths copied between projects, you have larkspur_web — one name you drop into any playbook, version in git, or publish to Galaxy. It is the Ansible analog of a Terraform module: a self-contained directory with a known shape and a single entry point.

The Standard Subdirectories

A role is a set of subdirectories, each holding one kind of thing. tasks/ holds the steps, handlers/ the notify-triggered actions, templates/ the Jinja2 files, files/ the static files, defaults/ and vars/ the variables at two precedence levels, and meta/ the role's metadata and dependencies. Two more — library/ for custom modules and module_utils/ for shared module code — appear only when a role ships its own plugins. The split is the role's structure, not decoration: each directory has a job, and Ansible loads it for that job.

The larkspur_web role — standard directory layout
roles/larkspur_web/
the role, addressed by this name
tasks/main.yml
entry point — auto-loaded, imports the rest
handlers/main.yml
reload nginx, restart gunicorn — auto-loaded
templates/
larkspur.conf.j2, app.env.j2 — found by bare name
files/
static files copied verbatim
defaults/main.yml
the public knobs — auto-loaded, weakest precedence
vars/main.yml
internal constants — auto-loaded, high precedence
meta/main.yml
galaxy_info and dependencies

The main.yml Auto-load Convention

The convention that makes a role work is auto-load. When the role runs, Ansible reads tasks/main.yml, handlers/main.yml, defaults/main.yml, and vars/main.yml automatically — you never reference these paths anywhere. That is the whole reason a role "just works" when dropped into roles/: applying the role by name finds main.yml in each directory and loads it. The flip side is strict: only main.yml auto-loads. A file named tasks/install.yml sits there ignored until something explicitly imports or includes it.

Templates and Files Resolve Relatively

Inside a role, template: and copy: search the role's own templates/ and files/ first. So template: src=larkspur.conf.j2 finds roles/larkspur_web/templates/larkspur.conf.j2 with no path written anywhere — you name the file, the role finds it. This relative resolution is what makes a role relocatable: move the directory, publish it, install it under a different parent, and the template references still resolve, because they were never absolute in the first place.

A task inside the role — bare filename, no path
# roles/larkspur_web/tasks/configure.yml
- name: Render the nginx site config
  ansible.builtin.template:
    src: larkspur.conf.j2        # found in roles/larkspur_web/templates/
    dest: /etc/nginx/sites-available/larkspur.conf
  notify: reload nginx

There is no templates/larkspur.conf.j2 in that src — and there must not be. Writing the directory in breaks the relative search the role gives you for free, because Ansible already prepends the role's templates/ path. Bare name in, the role's own search resolves it; that is the self-contained property a role trades on.

Splitting tasks/main.yml

A large role's tasks/main.yml should not be fifty tasks long — that is the same unreadable wall the flat playbook was, just relocated. Instead main.yml becomes a short table of contents that imports the real work: install.yml, configure.yml, service.yml. Each file owns one phase, and the entry point reads like an outline of what the role does, in order. The role stays legible as it grows, because growth lands in the imported files, not in the index.

tasks/main.yml as an index of imported phases
# roles/larkspur_web/tasks/main.yml
- ansible.builtin.import_tasks: install.yml
- ansible.builtin.import_tasks: configure.yml
- ansible.builtin.import_tasks: service.yml

Read that file and you know the role's shape without scrolling: it installs, configures, then manages the service. The bodies live in their own files, each focused on one concern, and main.yml stays three lines no matter how large the role gets.

What a Role Is, Concretely

Strip away the directories and a role is one thing: a portable, reusable bundle addressed by name. You drop larkspur_web into any playbook, version it in git, publish it to Galaxy, and apply it with a single line — larkspur_web — instead of wiring up a pile of file paths by hand. It is the Ansible unit of reuse, the direct counterpart to a Terraform module: a named directory with a known shape, a single entry point, and no dependency on where it sits.

Common Mistakes
  • Writing src: templates/larkspur.conf.j2 inside a role and breaking the relative resolution that would have found it for free — inside a role you name the file, not the path, because the role's own templates/ is already on the search.
  • Naming the entry file tasks/install.yml and expecting it to auto-load — only main.yml auto-loads, so the file sits inert until an explicit import_tasks or include_tasks pulls it in.
  • Dumping fifty tasks into one tasks/main.yml instead of splitting into imported files, so the role becomes the same unreadable wall the playbook was — the structure moved, the problem did not.
  • Putting a template under files/ or a static file under templates/, so copy: ships an un-rendered {{ }} file to the host or template: fails to find its source.
  • Treating a role as a folder of loose YAML you include by path, missing the whole point — a role is addressed by name and loaded by convention, not by hand-wired includes that defeat the relocatability.
Best Practices
  • Generate the skeleton with ansible-galaxy role init larkspur_web so the directory layout is correct from the first commit instead of hand-built and subtly wrong.
  • Keep tasks/main.yml a short index that imports install.yml, configure.yml, and service.yml, so the role reads like an outline and growth lands in the phase files.
  • Reference templates and files by bare name and let the role's relative search find them, keeping the role relocatable across projects and publishable as-is.
  • Delete the subdirectories the role does not use — an empty vars/ or library/ — rather than shipping empty scaffolding that implies content that is not there.
  • Put each kind of thing in its own directory and nothing else there, so a reader knows that handlers/ is only handlers and files/ only static files before opening a single one.
Comparable tools Terraform module the same reuse unit — a named, versioned directory of resources Puppet module · Chef cookbook the equivalent fixed layout in those tools A hand-included pile of task files the unstructured version a role replaces

Knowledge Check

A role has a file at tasks/setup.yml. Why does it not run when the role is applied?

  • Only main.yml auto-loads in each role directory; any other file must be explicitly imported or included
  • Task files must be named after the role itself, so this one would need to be renamed to larkspur_web.yml first
  • Ansible runs the files alphabetically and setup.yml sorts after main.yml, which halts the role early
  • Files under tasks/ only load when each one is explicitly listed in meta/main.yml

Inside the role, a task uses template: src=larkspur.conf.j2 with no directory. Why does this resolve?

  • Inside a role, template: searches the role's own templates/ directory first, so the bare filename is found there
  • Ansible searches the playbook's own current working directory for any file whose name happens to match the bare src
  • The template file must actually sit right next to tasks/main.yml for a bare name to resolve
  • Bare filenames always resolve against the control node's global /etc/ansible/templates directory

Why split a large role's tasks/main.yml into imported install.yml, configure.yml, and service.yml?

  • main.yml stays a short readable index of phases while the actual tasks live in focused, single-concern files
  • Ansible runs the separate imported files in parallel, so splitting genuinely speeds up the role
  • Only tasks in split-out files can be tagged, so a single combined main.yml cannot use --tags
  • Tasks living outside main.yml automatically gain higher precedence and quietly override your inventory variables

A static configuration file gets placed under the role's templates/ instead of files/. What is the consequence?

  • If applied with template: it is rendered as Jinja2 — and any literal {{ }} in it would be misinterpreted or error
  • Ansible refuses to load the entire role until every file sits in its matching directory
  • The file is silently skipped at apply time because the templates/ directory only ever accepts files with a .j2 extension
  • It loads perfectly fine, since files/ and templates/ are fully interchangeable inside any role

You got correct