Role Anatomy
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 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.
# 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.
# 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.
- Writing
src: templates/larkspur.conf.j2inside 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 owntemplates/is already on the search. - Naming the entry file
tasks/install.ymland expecting it to auto-load — onlymain.ymlauto-loads, so the file sits inert until an explicitimport_tasksorinclude_taskspulls it in. - Dumping fifty tasks into one
tasks/main.ymlinstead 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 undertemplates/, socopy:ships an un-rendered{{ }}file to the host ortemplate:fails to find its source. - Treating a role as a folder of loose YAML you
includeby path, missing the whole point — a role is addressed by name and loaded by convention, not by hand-wired includes that defeat the relocatability.
- Generate the skeleton with
ansible-galaxy role init larkspur_webso the directory layout is correct from the first commit instead of hand-built and subtly wrong. - Keep
tasks/main.ymla short index that importsinstall.yml,configure.yml, andservice.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/orlibrary/— 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 andfiles/only static files before opening a single one.
Knowledge Check
A role has a file at tasks/setup.yml. Why does it not run when the role is applied?
- Only
main.ymlauto-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.ymlfirst - Ansible runs the files alphabetically and
setup.ymlsorts aftermain.yml, which halts the role early - Files under
tasks/only load when each one is explicitly listed inmeta/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 owntemplates/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.ymlfor a bare name to resolve - Bare filenames always resolve against the control node's global
/etc/ansible/templatesdirectory
Why split a large role's tasks/main.yml into imported install.yml, configure.yml, and service.yml?
main.ymlstays 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.ymlcannot use--tags - Tasks living outside
main.ymlautomatically 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.j2extension - It loads perfectly fine, since
files/andtemplates/are fully interchangeable inside any role
You got correct