Ansible Lesson 27 – Role Directory Structure | Dataplexa
Section III · Lesson 27

Role Directory Structure in Depth

In this lesson

Every subdirectory explained defaults vs vars deep dive Task file splitting Role-relative paths Galaxy-ready structure

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).

VARIABLE PRECEDENCE — higher wins HIGHER PRIORITY → Extra vars (-e flag) ansible-playbook site.yml -e "nginx_port=8443" set_fact / registered vars Created at runtime with set_fact or register role vars/ ← HIGH PRECEDENCE roles/nginx/vars/main.yml — overrides group_vars Play vars / vars_files vars: block in play header or vars_files: host_vars / group_vars / inventory inventory/production/group_vars/webservers.yml role defaults/ ← LOWEST PRECEDENCE roles/nginx/defaults/main.yml — overridden by everything
Use defaults/ when…
The value is a sensible fallback that callers should commonly override
nginx_port: 80, nginx_worker_processes: 1
Any configurable option — port numbers, package names, user names, paths
Use vars/ when…
The value is an internal constant that should never be overridden externally
__nginx_packages (internal list), OS-specific paths set by an OS detection task
Values derived at role design time that callers have no reason to change

The 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.

Static

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.

Dynamic

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.

❌ Avoid — hardcoded path breaks portability
- name: Deploy nginx config
  ansible.builtin.template:
    src: roles/nginx/templates/nginx.conf.j2
    dest: /etc/nginx/nginx.conf
✓ Correct — bare filename, role-relative
- 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

1
meta/main.yml is complete — all galaxy_info fields populated, platforms listed, correct min Ansible version
2
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.
3
ansible-lint passes with zero violations — Galaxy displays the lint score and it affects discoverability. A failing score signals poor maintenance quality.
4
CI pipeline tests against all declared platforms — a GitHub Actions or Travis CI workflow that runs Molecule against each platform in the platforms list.
5
All configurable options are in defaults/, not vars/ — callers should be able to override anything documented in the README without forking the role.
6
Semantic version tag on the GitHub repo — Galaxy uses Git tags as release versions. Tag releases as 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.
Use 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.
Use bare filenames for copy and template sources — Ansible searches the role's files/ and templates/ directories automatically, keeping roles portable and self-contained.
Prefix handler names with the role name — prevents silent collisions when multiple roles are applied in the same play. The convention rolename | Handler description is widely adopted in the community.
A Galaxy-ready role requires a complete 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.