Ansible Course
Role Directory Structure in Depth
In this lesson
The role directory structure
was introduced in Lesson 23 and applied practically in Lesson 24. This lesson goes
deeper — covering every subdirectory in detail, explaining the precise rules for how
Ansible loads each one, and resolving the most common points of confusion: when to use
defaults/ versus vars/, how import_tasks differs
from include_tasks for task splitting, how role-relative file and template
references work, and how to structure a role so it passes ansible-lint
cleanly and can be published to Galaxy without modification. This lesson turns a working
role into a professional one.
The Complete Directory Layout
A fully populated role uses all eight standard directories. In practice most roles only need four or five — but knowing what every directory is for prevents you from putting things in the wrong place.
roles/
└── nginx/ # role name — used to call the role
├── tasks/
│ ├── main.yml # REQUIRED — entry point, loaded automatically
│ ├── install.yml # optional sub-file, imported from main.yml
│ ├── configure.yml # optional sub-file
│ └── vhosts.yml # optional sub-file
│
├── handlers/
│ └── main.yml # loaded automatically — handlers for this role
│
├── defaults/
│ └── main.yml # LOWEST variable precedence — overridable by callers
│
├── vars/
│ └── main.yml # HIGH variable precedence — internal constants
│
├── templates/
│ ├── nginx.conf.j2 # referenced by filename only in tasks
│ └── vhost.conf.j2
│
├── files/
│ ├── nginx.crt # static files — referenced by filename only
│ └── mime.types
│
├── meta/
│ └── main.yml # role metadata, Galaxy info, dependencies
│
├── tests/
│ ├── inventory # test inventory (localhost)
│ └── test.yml # test playbook (applies this role)
│
└── README.md # required for Galaxy — documents the role
defaults/ vs vars/ — The Deep Dive
The difference between
defaults/
and
vars/
is purely about variable precedence — and it is the most commonly misunderstood aspect
of role design. Getting this wrong makes your role either impossible to customise
(everything in vars/) or unsafe to rely on (everything in
defaults/ where any inventory variable accidentally overrides it).
nginx_port: 80,
nginx_worker_processes: 1__nginx_packages (internal list),
OS-specific paths set by an OS detection taskThe OS-specific vars pattern — a legitimate use of vars/
# tasks/main.yml — load OS-specific internal vars at runtime
- name: Load OS-specific variables
ansible.builtin.include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_distribution | lower }}-{{ ansible_distribution_major_version }}.yml"
- "{{ ansible_os_family | lower }}.yml"
- "default.yml"
# This loads vars/debian.yml on Debian, vars/redhat.yml on RHEL, etc.
# These files contain OS-specific package names and service names
# that callers should never override — they are role internals.
# vars/debian.yml — OS-specific internal constants
---
__nginx_package: nginx
__nginx_service: nginx
__nginx_conf_dir: /etc/nginx
__nginx_log_dir: /var/log/nginx
# vars/redhat.yml
---
__nginx_package: nginx
__nginx_service: nginx
__nginx_conf_dir: /etc/nginx
__nginx_log_dir: /var/log/nginx
# Note: double underscore prefix (__) is a community convention for
# "internal variable — not intended for callers to override"
Task File Splitting — import_tasks vs include_tasks
As a role's task list grows, splitting it across multiple files keeps each section focused and navigable. There are two directives for this — and choosing the wrong one causes subtle bugs with tags and handlers.
ansible.builtin.import_tasks
Processed at
parse time before the play runs. Tags applied to the
import_tasks line cascade into every task in the imported file.
when: conditions are also inherited. Use for unconditional,
always-present sub-task files — the standard choice for role task splitting.
ansible.builtin.include_tasks
Processed at
runtime when the task executes. The filename can be a variable
— enabling dynamic file selection. Tags on the include_tasks line
do not propagate into included tasks. Use when the task file to load
depends on a variable (e.g. OS family).
# roles/nginx/tasks/main.yml — recommended splitting pattern
---
# import_tasks: static — tags propagate, file is always loaded
- name: Install Nginx packages
ansible.builtin.import_tasks: install.yml
tags: [packages, nginx] # these tags apply to ALL tasks in install.yml
- name: Configure Nginx
ansible.builtin.import_tasks: configure.yml
tags: [config, nginx]
- name: Manage virtual hosts
ansible.builtin.import_tasks: vhosts.yml
tags: [config, nginx, vhosts]
# include_tasks: dynamic — use when file depends on a variable
- name: Load OS-specific tasks
ansible.builtin.include_tasks: "{{ ansible_os_family | lower }}.yml"
# Loads debian.yml on Debian/Ubuntu, redhat.yml on RHEL/CentOS
# Tags cannot cascade into dynamically included files
The Compiled vs Interpreted Analogy
import_tasks is like compiled code
— everything is resolved before execution begins. The compiler (Ansible's parser) reads
all imported files upfront, so tags and conditions can cascade through the entire
resolved structure. include_tasks is like an interpreted script — it is
evaluated line by line at runtime, so the file to include can be determined dynamically,
but the interpreter cannot apply tags to code it has not seen yet. Reach for
import_tasks by default; only switch to include_tasks when
you need the dynamic file selection.
Role-Relative Paths for Files and Templates
One of the conveniences that makes roles
self-contained is that the copy and template modules
automatically look inside the role's own files/ and
templates/ directories when given a bare filename — no path prefix
needed. This makes roles portable: move the role directory and all file references
continue to work.
- name: Deploy nginx config
ansible.builtin.template:
src: roles/nginx/templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
# roles/nginx/tasks/configure.yml
# All file and template references use bare filenames — role-relative search
- name: Deploy Nginx main configuration
ansible.builtin.template:
src: nginx.conf.j2 # found in roles/nginx/templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
notify: Reload Nginx
- name: Deploy mime.types static file
ansible.builtin.copy:
src: mime.types # found in roles/nginx/files/mime.types
dest: /etc/nginx/mime.types
owner: root
mode: "0644"
- name: Deploy SSL certificate
ansible.builtin.copy:
src: nginx.crt # found in roles/nginx/files/nginx.crt
dest: /etc/ssl/certs/nginx.crt
mode: "0644"
- name: Deploy virtual host templates
ansible.builtin.template:
src: vhost.conf.j2 # found in roles/nginx/templates/vhost.conf.j2
dest: "/etc/nginx/conf.d/{{ item.name }}.conf"
loop: "{{ nginx_vhosts }}"
How Ansible finds role files
When a task inside a role calls
copy: src: filename, Ansible searches in this order: the role's
files/ directory, then the playbook's files/ directory.
For template: src: filename.j2, it searches the role's
templates/ directory first. This lookup chain means a playbook can
override a role's default files without modifying the role itself — useful for
environment-specific static files.
Handlers in Roles
Role handlers are defined in
handlers/main.yml and behave identically to play-level handlers —
they run once at the end of the play when notified by a changed task. Two important
scoping rules apply: handlers in a role are available to the entire play (not just
the role's own tasks), and handler names must be unique across all roles applied
in the same play.
# roles/nginx/handlers/main.yml
---
# Handler names must be globally unique within a play
# Convention: prefix with the role name to prevent collisions
- name: nginx | Reload Nginx
ansible.builtin.service:
name: "{{ __nginx_service }}"
state: reloaded
- name: nginx | Restart Nginx
ansible.builtin.service:
name: "{{ __nginx_service }}"
state: restarted
- name: nginx | Validate config
ansible.builtin.command:
cmd: nginx -t
changed_when: false
# Can be notified before restart to validate before applying
# Notifying role handlers from tasks — use the full handler name
- name: Deploy Nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: "nginx | Reload Nginx" # must match handlers/main.yml exactly
- name: Deploy Nginx TLS certificate
ansible.builtin.copy:
src: nginx.crt
dest: /etc/ssl/certs/nginx.crt
notify:
- "nginx | Validate config" # validate first
- "nginx | Restart Nginx" # then restart
meta/main.yml — Complete Reference
A complete meta/main.yml
serves three purposes: it declares the role's dependencies (which run automatically
before it), it provides Galaxy metadata for discoverability, and it documents the
supported platforms so CI can test the right matrix.
# roles/nginx/meta/main.yml
---
galaxy_info:
role_name: nginx # must match the role directory name
author: yourname
description: >
Installs, configures, and manages Nginx on Debian and RedHat systems.
Supports virtual hosts, SSL, and custom worker configuration.
license: MIT # SPDX identifier
min_ansible_version: "2.14"
min_ansible_collection_version: "0"
platforms:
- name: Ubuntu
versions:
- "20.04"
- "22.04"
- name: Debian
versions:
- "11"
- "12"
- name: EL # Enterprise Linux (RHEL, CentOS, Rocky)
versions:
- "8"
- "9"
galaxy_tags:
- nginx
- web
- proxy
- http
- ssl
# Role dependencies — run before this role, automatically
dependencies:
- role: common # base system configuration
# Version-pin Galaxy dependencies
- role: geerlingguy.certbot
version: "6.0.0"
vars:
certbot_admin_email: "ops@example.com"
Galaxy-Ready Checklist
A role that is structured correctly for
your own project is also ready to publish to Galaxy. Work through this checklist
before running ansible-galaxy role import.
Pre-publication checklist
meta/main.yml is complete
— all galaxy_info fields populated, platforms listed, correct
min Ansible version
README.md documents all
defaults/ variables — every configurable option listed
with its default value and a description. Galaxy displays the README as the
role documentation page.
ansible-lint passes with zero
violations — Galaxy displays the lint score and it affects
discoverability. A failing score signals poor maintenance quality.
platforms list.
defaults/, not vars/ — callers should be
able to override anything documented in the README without forking the role.
1.0.0, 1.1.0 etc. so consumers can pin to a
specific version in their requirements.yml.
Handler Names Must Be Unique Across All Roles in a Play
When a play applies multiple
roles, all role handlers share the same namespace. If your nginx role
and your app role both define a handler named Restart
service, the second definition silently overwrites the first — and the
wrong service gets restarted. Always prefix handler names with the role name:
nginx | Reload Nginx, app | Restart application.
This single convention prevents an entire class of hard-to-debug ordering bugs.
Key Takeaways
defaults/ is for everything callers might override;
vars/ is for internal constants — putting configurable
options in vars/ makes them nearly impossible to override and
breaks the role for any caller whose environment differs from yours.
import_tasks for standard task file splitting
— tags cascade into imported tasks at parse time. Only switch to
include_tasks when the filename itself must be determined
dynamically at runtime.
copy and template
sources — Ansible searches the role's files/ and
templates/ directories automatically, keeping roles portable
and self-contained.
rolename | Handler description is widely adopted
in the community.
meta/main.yml,
a documented README.md, and a passing ansible-lint run
— these three things together make a role trustworthy to any engineer who
finds it on Galaxy.
Teacher's Note
Open the PostgreSQL role from
Lesson 24 and audit it against the Galaxy-ready checklist in this lesson — check
whether all configurable options are in defaults/, add the
rolename | prefix to the handlers, and write out a basic
README.md listing the defaults variables. That exercise transforms a
working role into a publishable one.
Practice Questions
1. Which directive statically includes a task file at parse time so that tags applied to the directive cascade into every task inside the included file?
2. A role variable should be
configurable by callers through their group_vars. In which role
file should it be defined?
3. Following community naming
conventions, what should the handler for reloading Nginx be named inside the
nginx role?
Quiz
1. A role needs to load a different
task file depending on ansible_os_family. Which directive and pattern
achieves this?
2. A role author puts
nginx_port: 80 in vars/main.yml instead of
defaults/main.yml. What problem does this cause?
3. A role task uses
ansible.builtin.copy: src: nginx.crt with no path prefix. Where
does Ansible look for this file?
Up Next · Lesson 28
Ansible Vault
Learn to encrypt secrets, passwords, and credentials using Ansible Vault — the built-in AES-256 encryption system that keeps sensitive data safe in version control without sacrificing automation convenience.