Module 05 — Roles & Reusability
Updated 20 August 2026
Packaging tasks, handlers, variables and templates into something a team can share, version and reuse. This is the boundary between writing playbooks and building automation other people depend on.
🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)
Prerequisite: Modules 01–04. You now have every ingredient a role contains — this module is about the container.
Part A · What a role is
A1 · The problem roles solve
A role is that box: its tasks, templates, files, handlers and default settings travel together. And the test of a well-packed one is the same as with furniture — a role that also tries to assemble the bookshelf, or that only works if the bookshelf is already there, is a badly packed box.
A single-file playbook works until it does not. The failure is predictable:
Nginx, PostgreSQL, monitoring and firewall config all interleaved.
Nobody can find anything. Two people cannot edit it without conflicting. Nothing can be reused on the next project. Testing means running the whole thing.
Each role owns one concern, has its own defaults, its own handlers, its own templates.
Independently reviewable, independently testable, reusable across projects, and shareable through Galaxy.
A role is a directory with a fixed layout. That is the whole idea — Ansible knows where to look for each kind of content, so you do not have to specify paths.
# site.yml becomes this thin
---
- name: Configure web tier
hosts: web
become: true
roles:
- common
- nginx
- monitoringA2 · The directory structure
A role's directories are that convention. Because templates/ always means templates and tasks/main.yml is always where things start, Ansible can find files without being told where they are — which is exactly why src: nginx.conf.j2 needs no path at all. It is also why another engineer can open your role and know their way around it immediately.
roles/nginx/
defaults/main.yml <- default variables. LOWEST precedence (level 2). Put almost everything here
vars/main.yml <- internal constants. HIGH precedence (level 15). Use sparingly
tasks/main.yml <- the entry point. What the role does
handlers/main.yml <- handlers this role provides
templates/ <- Jinja2 templates. Found automatically by the template module
files/ <- static files. Found automatically by copy and script
meta/main.yml <- dependencies, galaxy metadata, allow_duplicates
meta/argument_specs.yml <- role argument validation (2.11+). Underused and excellent
library/ <- custom modules only this role needs
module_utils/ <- shared Python for those modules
filter_plugins/ <- custom Jinja2 filters
tests/ <- test playbook and inventory
README.md <- what it does, every variable, an example. Not optional in a teamSo a role with only tasks/main.yml is a perfectly valid role. Directories you do not need simply do not exist.
Splitting a large role
roles/nginx/tasks/
main.yml <- imports the others, in order
install.yml
configure.yml
service.yml# roles/nginx/tasks/main.yml
---
- ansible.builtin.import_tasks: install.yml
- ansible.builtin.import_tasks: configure.yml
- ansible.builtin.import_tasks: service.ymlA3 · Where Ansible looks for roles
Diagram source
flowchart TD
A["roles:<br>- nginx"] --> B{"./roles/nginx/<br>next to the playbook?"}
B -->|"Found"| Z["Use it"]
B -->|"No"| C{"roles_path in ansible.cfg?"}
C -->|"Found"| Z
C -->|"No"| D{"~/.ansible/roles/"}
D -->|"Found"| Z
D -->|"No"| E{"/usr/share/ansible/roles/<br>/etc/ansible/roles/"}
E -->|"Found"| Z
E -->|"No"| F["ERROR: the role was not found"]
style Z fill:#D1FAE5,stroke:#059669,stroke-width:2px
style F fill:#FEE2E2,stroke:#DC2626# ansible.cfg - make it explicit rather than relying on defaults
[defaults]
roles_path = ./roles:./galaxy_roles🧪 Exercise A3.1 — Scaffold a role and see what you get
ansible-galaxy role init roles/nginx
find roles/nginx -type f | sort
cat roles/nginx/tasks/main.yml
cat roles/nginx/meta/main.yml✅ Expected result — click to reveal
- Role roles/nginx was created successfullyroles/nginx/README.md
roles/nginx/defaults/main.yml
roles/nginx/files/.gitkeep
roles/nginx/handlers/main.yml
roles/nginx/meta/main.yml
roles/nginx/tasks/main.yml
roles/nginx/templates/.gitkeep
roles/nginx/tests/inventory
roles/nginx/tests/test.yml
roles/nginx/vars/main.yml---
# tasks file for roles/nginxgalaxy_info:
author: your name
description: your role description
license: license (GPL-2.0-or-later, MIT, etc)
min_ansible_version: 2.1
galaxy_tags: []
dependencies: []Every file is a stub with a comment and nothing else. ansible-galaxy role init gives you the skeleton, not content — which is exactly what you want, because it means the layout is always correct and consistent across a team.
Two things to do immediately after scaffolding:
- Fill in README.md. Every variable the role accepts, its default, and a working example. In a team, an undocumented role gets rewritten by the next person rather than reused — which defeats the entire purpose.
- Delete the directories you will not use. An empty vars/ and files/ tree is noise in code review. Keeping only what the role actually uses makes its shape obvious at a glance.
Note min_ansible_version: 2.1 in the generated metadata — it is a stale default from the scaffolder. Set it to something honest, or remove it.
🎯 Interview questions — Role structure
Q. What is an Ansible role and why use one?
A directory with a fixed, conventional layout that packages tasks, handlers, variables, templates, files and metadata for one concern.
The benefits: reuse across playbooks and projects, independent review and testing, parallel work without merge conflicts, and shareability through Galaxy. Ansible knows where to find each kind of content, so paths inside a role are bare filenames.
Q. Name the role directories and what each holds.
tasks/ the entry point, handlers/ the role's handlers, defaults/ default variables at lowest precedence, vars/ internal constants at high precedence, templates/ Jinja2 templates, files/ static files, meta/ dependencies and Galaxy metadata, library/ role-local custom modules, tests/ a test playbook.
All are optional, and main.yml is the automatically-loaded filename in each — any other name is ignored unless you explicitly include it.
Q. How does Ansible find a role by name?
./roles/ relative to the playbook first, then roles_path from ansible.cfg, then ~/.ansible/roles/, then the system paths such as /usr/share/ansible/roles/ and /etc/ansible/roles/. First match wins.
Good practice: set roles_path explicitly and keep your own roles separate from Galaxy-installed ones — for example roles_path = ./roles:./galaxy_roles with the second gitignored — so it is always obvious what your team wrote.
Q. How do you organise a role whose tasks/main.yml has grown too large?
Split it into install.yml, configure.yml, service.yml and have main.yml import_tasks them in order. Paths are relative to tasks/, so they are bare filenames.
Prefer import_tasks over include_tasks here so the whole role stays visible to --list-tasks and taggable per task — Module 03 Part D2.
Part B · Variables in roles
B1 · defaults/ vs vars/ — the decision that defines a role's usability
defaults/ is the recommended price; vars/ is the no-haggling sign. Both are legitimate, and the mistake is putting a negotiable item behind the wrong sign: a colleague sets the value in their inventory, absolutely nothing happens, and everything they can see says they did it right. That is the wedding-invitation precedence list from Module 02 biting someone in practice — vars/ sits at level 15, far above their inventory.
You met the precedence numbers in Module 02. Here is what they mean when you are the one writing the role.
The lowest of any real variable source.
Inventory, group_vars, host_vars, play vars and -e can all override it.
This is the role's public API. Put almost everything here.
Beats inventory, group_vars, host_vars and play vars.
Only -e, task vars and set_fact beat it.
Internal constants the user must not change. Use sparingly.
# roles/nginx/defaults/main.yml -- the public API
nginx_port: 80
nginx_worker_processes: auto
nginx_enable_tls: false
nginx_server_name: "_"
nginx_log_level: warn
# roles/nginx/vars/main.yml -- internal, platform-specific, not user-facing
nginx_package_name: nginx
nginx_config_path: /etc/nginx/nginx.conf
nginx_service_name: nginxHours are lost, because everything looks right. The value is in the inventory, the role reads the variable, and the wrong number comes out.
The rule: if a consumer might ever want to change it, it belongs in defaults/. Reserve vars/ for values that are genuinely internal — package names, config paths, service names.
B2 · Naming — prefix everything
Every role in a play shares one fridge. A variable called port defined in two different roles will collide, and which value survives depends on the order things went in — which is not something you want your production config to depend on. nginx_port is your name on the box.
# ❌ BAD - these are global once the role is included
port: 80
config_path: /etc/nginx/nginx.conf
enabled: true
# ✅ GOOD - namespaced to the role
nginx_port: 80
nginx_config_path: /etc/nginx/nginx.conf
nginx_enabled: truePrefix every variable with the role name. It is the single most important role convention, and every well-written Galaxy role follows it.
B3 · argument_specs — validating a role's inputs
argument_specs.yml is that dropdown. It checks types, required-ness and allowed values before the role runs a single task, instead of letting a nonsense value travel quietly into a config file and surface as a service that will not start at three in the morning.
Since Ansible 2.11 a role can declare and validate the arguments it accepts. It is genuinely excellent and almost nobody uses it.
# roles/nginx/meta/argument_specs.yml
---
argument_specs:
main:
short_description: Install and configure nginx
options:
nginx_port:
type: int
default: 80
description: Port nginx listens on
nginx_enable_tls:
type: bool
default: false
description: Enable TLS
nginx_tls_cert:
type: str
required: false
description: Path to the certificate. Required when nginx_enable_tls is true
nginx_log_level:
type: str
default: warn
choices: [debug, info, notice, warn, error, crit]Bringing this up unprompted in an interview is a strong signal, because it is a modern feature that separates people who keep current from people who learned Ansible in 2018 and stopped.
🧪 Exercise B3.1 — Watch vars/ silently defeat your inventory
mkdir -p roles/demo/{defaults,vars,tasks} group_vars
printf -- "---\ndemo_from_defaults: from_role_defaults\n" > roles/demo/defaults/main.yml
printf -- "---\ndemo_from_vars: from_role_vars\n" > roles/demo/vars/main.yml
printf -- "---\ndemo_from_defaults: from_group_vars\ndemo_from_vars: from_group_vars\n" > group_vars/all.yml
cat > roles/demo/tasks/main.yml <<'EOF'
---
- ansible.builtin.debug:
msg:
- "defaults version won by: {{ demo_from_defaults }}"
- "vars version won by: {{ demo_from_vars }}"
EOF
printf -- "---\n- hosts: localhost\n gather_facts: false\n roles:\n - demo\n" > roletest.yml
ansible-playbook roletest.yml✅ Expected result — click to reveal
TASK [demo : ansible.builtin.debug] ********************************
ok: [localhost] => {
"msg": [
"defaults version won by: from_group_vars",
"vars version won by: from_role_vars"
]
}Identical group_vars/all.yml, opposite outcomes.
The variable defined in the role's defaults/ (level 2) was successfully overridden by group_vars (level 4). That is the designed behaviour and exactly what defaults/ is for.
The variable defined in the role's vars/ (level 15) ignored group_vars entirely. No warning, no error — the group_vars value simply had no effect.
Now put yourself on the receiving end. You are handed this role, you set demo_from_vars in your inventory as documented, and nothing happens. You check the spelling, check the group name, check ansible-inventory --host, and it all looks correct — because it is correct. The bug is in the role's design, not your configuration.
Confirm with ansible-playbook roletest.yml -e demo_from_vars=from_extra_vars — extra vars at level 22 beat even vars/, which is the only override left to you.
🎯 Interview questions — Role variables
Q. When do you put a variable in defaults/ versus vars/?
defaults/ for anything a consumer might want to change — ports, versions, paths, feature flags. It is precedence level 2, the lowest, so inventory and group_vars can override it. This is the role's public API.
vars/ for internal constants the role depends on — package names, config file paths, platform-specific service names. It is level 15, above inventory and group_vars.
The rule of thumb: almost everything belongs in defaults/. A user-facing setting in vars/ produces silent, hours-long debugging where a correct-looking inventory value simply has no effect.
Q. Why prefix role variables with the role name?
Because role variables are not scoped to the role — once it runs they live in the play's variable space. Two roles that both define port collide, and the winner depends on invocation order, so the bug only appears when someone adds a second role.
nginx_port rather than port. Every well-written Galaxy role does this.
Q. How do you validate the arguments a role accepts?
meta/argument_specs.yml, available since 2.11. Declare each option's type, default, choices and whether it is required, and Ansible validates before the role runs, failing with a precise message.
It also serves as machine-readable documentation rendered by ansible-doc -t role <name>.
The alternative for older versions is an assert block as the first task in tasks/main.yml — less elegant but the same intent.
Part C · Invoking roles
C1 · Three ways, and they behave differently
The roles: section is what was already booked before the day started; import_role is a slot written in advance; include_role is deciding on the spot. That last one is exactly why --list-tasks cannot see inside an include_role — at listing time, the decision genuinely has not been made yet. It is the same photocopy-versus-"see page 40" split as Module 03 D2.
# 1. The roles: keyword - runs BEFORE tasks:, static
- hosts: web
roles:
- nginx
- role: monitoring
vars:
monitoring_port: 9100
# 2. import_role - STATIC, inserted where you put it
- hosts: web
tasks:
- ansible.builtin.import_role:
name: nginx
# 3. include_role - DYNAMIC, resolved at run time
- hosts: web
tasks:
- ansible.builtin.include_role:
name: "{{ selected_role }}" # variable name - only include can do this
loop: "{{ role_list }}" # looping - only include can do this| roles: | import_role | include_role | |
|---|---|---|---|
| When processed | Parse time | Parse time | Run time |
| Runs | Before tasks: | Exactly where placed | Exactly where placed |
| Variable role name | ❌ | ❌ | ✅ |
| Can be looped | ❌ | ❌ | ✅ |
| Visible to --list-tasks | ✅ | ✅ | ❌ |
| Role vars exposed to the play | ✅ whole play | ✅ whole play | ❌ scoped to the role |
| Tag inheritance | Inherited by all tasks | Inherited by all tasks | Only on the include — needs apply: |
Variable scope. With roles: or import_role, the role's defaults and vars are visible to the entire play, including tasks that run before the role. With include_role they are scoped to the role. So migrating from one to the other can make a variable mysteriously undefined.
Tags. A tag on import_role applies to every task inside it. A tag on include_role applies only to the include task itself — so --tags nginx runs the include and then nothing, because the role's inner tasks are untagged. The fix is apply:.
# Pushing tags and conditions down into a dynamically included role
- ansible.builtin.include_role:
name: nginx
apply:
tags: [nginx, webserver]
become: true
tags: [nginx, webserver] # the include itself ALSO needs the tagC2 · Role parameters and conditional roles
- hosts: web
roles:
# Parameters - precedence level 20, above almost everything
- role: nginx
vars:
nginx_port: 8080
# Conditional - the when applies to EVERY task in the role
- role: monitoring
when: enable_monitoring | default(true) | bool
# Tags - inherited by every task in the role
- role: firewall
tags: [security, firewall]C3 · Dependencies in meta/main.yml
# roles/nginx/meta/main.yml
---
dependencies:
- role: common
- role: firewall
vars:
firewall_allowed_ports: [80, 443]
allow_duplicates: false # the default- They run before the role's own tasks — always, and before the role even begins.
- They are deduplicated. By default Ansible runs a role only once per play even if several roles depend on it, provided the parameters are identical. Different parameters means it runs again.
- allow_duplicates: true overrides that, letting a role run repeatedly — needed for roles designed to be applied once per item, such as "create a vhost".
The practical warning: deep dependency chains make execution order hard to predict, and a dependency failing takes down a role that looks unrelated. Many experienced teams avoid meta dependencies entirely and compose explicitly in the playbook instead, because explicit ordering is easier to reason about.
🧪 Exercise C3.1 — Watch a tag silently do nothing on include_role
---
- name: Tag behaviour differs by invocation
hosts: localhost
gather_facts: false
tasks:
- name: Static import - tag reaches the inner tasks
ansible.builtin.import_role:
name: demo
tags: [imported]
- name: Dynamic include - tag stops at the include
ansible.builtin.include_role:
name: demo
tags: [included]
- name: Dynamic include done properly
ansible.builtin.include_role:
name: demo
apply:
tags: [fixed]
tags: [fixed]ansible-playbook tagtest.yml --tags imported
ansible-playbook tagtest.yml --tags included # the interesting one
ansible-playbook tagtest.yml --tags fixed✅ Expected result — click to reveal
--tags imported — works as expected:
TASK [demo : ansible.builtin.debug] *** ok: [localhost] => {"msg": [...]}--tags included — the trap:
TASK [Dynamic include - tag stops at the include] ***
included: /home/zaeem/lab/roles/demo/tasks/main.yml for localhost
PLAY RECAP ****************************************
localhost : ok=1 changed=0 skipped=0Read that carefully. The include task itself ran — you can see included: in the output. But no task from the role executed. ok=1 counts only the include. The role's inner tasks are untagged, so --tags included skipped every one of them.
No error. No warning. Just a play that appears to run and does nothing. If that role installed a security patch, you would have a green pipeline and an unpatched fleet.
--tags fixed — correct:
TASK [demo : ansible.builtin.debug] *** ok: [localhost] => {"msg": [...]}apply: pushed the tag down onto every task inside the role. Note you need the tag in both places: on apply: so the inner tasks carry it, and on the include task itself so the include is not skipped before it can run.
🎯 Interview questions — Invoking roles
Q. What is the difference between the roles: keyword, import_role and include_role?
roles: is static and runs before the play's tasks: section. import_role is static and runs exactly where you place it. include_role is dynamic, resolved at run time.
Only include_role can take a variable role name or be looped, because those need runtime data. Only the static forms are visible to --list-tasks and usable with --start-at-task.
Two behavioural differences that matter: the static forms expose the role's variables to the whole play, while include_role scopes them to the role; and tags on include_role apply only to the include itself unless you use apply:.
Q. --tags nginx runs your include_role but nothing inside it happens. Why?
A tag on include_role applies only to the include task, not to the role's inner tasks — which remain untagged and are therefore skipped.
Fix with apply: to push the tag down onto every task in the role, and keep the tag on the include task as well so the include itself is not skipped.
This fails silently — the play reports success while doing nothing — which is what makes it dangerous rather than merely annoying.
Q. How do role dependencies work, and why do teams avoid them?
dependencies: in meta/main.yml. They run before the role's own tasks, and are deduplicated — a role runs once per play even if several roles depend on it, unless the parameters differ or allow_duplicates: true is set.
Many experienced teams avoid meta dependencies and compose roles explicitly in the playbook instead, because deep dependency chains make execution order hard to predict and a failure in a dependency takes down a role that appears unrelated. Explicit ordering is easier to review and easier to debug.
Q. What precedence do role parameters have?
Level 20 — above set_fact and registered variables, above task vars, above everything except include params and extra vars.
The practical consequence: a parameter passed at the call site beats a value the role computes internally with set_fact, which occasionally surprises role authors.
Q. How do you run a role only on some hosts or under a condition?
- role: monitoring with when: — the condition is applied to every task in the role, evaluated per host.
Or restrict at the play level with a narrower hosts: pattern, which is usually cleaner because it is visible in --list-hosts and does not evaluate a condition hundreds of times.
Part D · Building production-quality roles
D1 · One role, one concern
"Installs and configures nginx" — fine, that is one concern.
"Installs nginx and PostgreSQL and configures monitoring" — three roles.
A role should be composable. nginx should not install PostgreSQL because your current app needs both; the playbook composes them.
D2 · Cross-platform roles
vars/Debian.yml and vars/RedHat.yml are the adapters. The role's logic stays completely identical; only the package name and the config path change, chosen from ansible_facts['os_family'] once you know which country you are in. That is the doctor's check-up from Module 02 paying off — the facts were gathered before you needed them precisely so this choice could be automatic.
The standard pattern for supporting several distributions:
roles/nginx/vars/
Debian.yml nginx_package: nginx nginx_conf: /etc/nginx/nginx.conf
RedHat.yml nginx_package: nginx nginx_conf: /etc/nginx/nginx.conf
default.yml fallback values# roles/nginx/tasks/main.yml
---
- name: Load platform-specific variables
ansible.builtin.include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_facts['distribution'] }}-{{ ansible_facts['distribution_major_version'] }}.yml"
- "{{ ansible_facts['distribution'] }}.yml"
- "{{ ansible_facts['os_family'] }}.yml"
- default.yml
tags: always # Module 03 - or --tags skips it and everything breaks
- name: Fail early on an unsupported platform
ansible.builtin.assert:
that: ansible_facts['os_family'] in ['Debian', 'RedHat']
fail_msg: "This role does not support {{ ansible_facts['os_family'] }}"
- ansible.builtin.import_tasks: install.yml
- ansible.builtin.import_tasks: configure.ymltags: always is not optional here. Without it, --tags configure skips the variable load and every subsequent task fails on undefined variables — a bug that looks like a variable problem and is actually a tagging problem. Module 03 Part D1.
Note this is one of the few places with_first_found remains idiomatic; elsewhere prefer loop.
D3 · Handlers and listen
listen: is announcing the topic instead of the names. A task notifies "restart web stack", and every handler subscribed to that topic reacts — so renaming or splitting a handler later does not silently break the twenty tasks that used to call it by name. Calling handlers by name is fine in a small playbook; listen: is what makes a shared role survive being maintained by other people.
# roles/nginx/handlers/main.yml
---
- name: Reload nginx
ansible.builtin.service:
name: "{{ nginx_service_name }}"
state: reloaded
listen: "restart web stack"
- name: Reload php-fpm
ansible.builtin.service:
name: php-fpm
state: reloaded
listen: "restart web stack"# A task notifies the TOPIC, not a handler name
- ansible.builtin.template:
src: nginx.conf.j2
dest: "{{ nginx_config_path }}"
notify: "restart web stack" # both handlers fireIt also means renaming a handler does not break every task that notified it — which, in a shared role consumed by other teams, is the difference between a safe refactor and a breaking change.
D4 · files/ and templates/ resolution
Inside a role, copy and template search automatically:
- ansible.builtin.template:
src: nginx.conf.j2 # finds roles/nginx/templates/nginx.conf.j2
dest: "{{ nginx_config_path }}"
- ansible.builtin.copy:
src: index.html # finds roles/nginx/files/index.html
dest: /var/www/html/index.htmlThe search order for a bare src inside a role is: the role's files/ or templates/, then the role directory itself, then the playbook's files/ or templates/, then the playbook directory. First match wins, which means a file in the role shadows one of the same name beside the playbook.
D5 · The README is part of the role
- What the role does, in one sentence
- Every variable it accepts, with defaults and whether it is required
- Supported platforms
- A working example play
- Any dependency the role assumes is already satisfied
meta/argument_specs.yml covers the variable table in machine-readable form and renders through ansible-doc -t role, but prose describing why and when still belongs in the README.
🧪 Exercise D5.1 — Build a complete, cross-platform role
ansible-galaxy role init roles/webserver# roles/webserver/defaults/main.yml
---
webserver_port: 80
webserver_server_name: "_"
webserver_enable_tls: false
webserver_document_root: /var/www/html# roles/webserver/vars/Debian.yml
---
webserver_package: nginx
webserver_service: nginx
webserver_config_path: /etc/nginx/sites-available/default
webserver_user: www-data# roles/webserver/vars/RedHat.yml
---
webserver_package: nginx
webserver_service: nginx
webserver_config_path: /etc/nginx/conf.d/default.conf
webserver_user: nginx# roles/webserver/tasks/main.yml
---
- name: Load platform variables
ansible.builtin.include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_facts['os_family'] }}.yml"
- default.yml
tags: always
- name: Verify the platform is supported
ansible.builtin.assert:
that: ansible_facts['os_family'] in ['Debian', 'RedHat']
fail_msg: "webserver role does not support {{ ansible_facts['os_family'] }}"
- name: Install the web server
ansible.builtin.package:
name: "{{ webserver_package }}"
state: present
- name: Deploy the configuration
ansible.builtin.template:
src: vhost.conf.j2
dest: "{{ webserver_config_path }}"
owner: root
group: root
mode: "0644"
validate: nginx -t -c %s
notify: "reload webserver"
- name: Ensure it is running and enabled
ansible.builtin.service:
name: "{{ webserver_service }}"
state: started
enabled: true# roles/webserver/handlers/main.yml
---
- name: Reload the web server
ansible.builtin.service:
name: "{{ webserver_service }}"
state: reloaded
listen: "reload webserver"✅ Expected result — and the seven decisions in it — click to reveal
TASK [webserver : Load platform variables] ************* ok: [web01]
TASK [webserver : Verify the platform is supported] **** ok: [web01]
TASK [webserver : Install the web server] ************** changed: [web01]
TASK [webserver : Deploy the configuration] ************ changed: [web01]
TASK [webserver : Ensure it is running and enabled] **** changed: [web01]
RUNNING HANDLER [webserver : Reload the web server] **** changed: [web01]
PLAY RECAP: web01 : ok=6 changed=3Second run:
PLAY RECAP: web01 : ok=5 changed=0Seven decisions, each worth being able to justify:
- Everything user-facing in defaults/ — port, server name, TLS flag, document root. All overridable from group_vars.
- Everything platform-internal in vars/<os_family>.yml — package name, service name, config path. Not meant to be overridden, and correctly at high precedence.
- webserver_ prefix on every variable — no collision with another role's port or service.
- tags: always on the variable load — otherwise --tags configure breaks everything downstream.
- assert for platform support — fails in two seconds with a clear message rather than a confusing "package not found" four tasks later.
- validate: on the template — a broken nginx config that reaches disk and triggers a reload takes the site down.
- listen: rather than a handler name — a consuming playbook can notify "reload webserver" without knowing the handler is called Reload the web server, so renaming it later is not a breaking change.
Note the task name prefix in the output — TASK [webserver : ...]. Ansible prefixes every task with its role name automatically, which is why role-based output is far easier to read on a large run than a flat playbook.
🎯 Interview questions — Building roles
Q. How do you make a role work across distributions?
Keep platform-specific values in vars/<os_family>.yml and load them at the top of tasks/main.yml with include_vars plus with_first_found, cascading from most specific to most general — Ubuntu-22.yml, Ubuntu.yml, Debian.yml, default.yml.
Tag that task always, or --tags runs skip the variable load and everything downstream fails on undefined variables.
Add an assert for unsupported platforms so it fails immediately with a clear message. And prefer portable modules like package over yum/apt where the options allow.
Q. What is listen: on a handler and why does it matter in roles?
It lets a handler subscribe to a topic rather than being notified by its own name, and several handlers can listen to the same topic so one notify fires all of them.
In roles it decouples the notifier from handler names — a task in one role can notify a topic that another role's handlers subscribe to, and renaming a handler does not break every task that notified it. In a shared role that is the difference between a safe refactor and a breaking change.
Q. Where does template: src=nginx.conf.j2 look inside a role?
The role's templates/ directory first, then the role directory, then the playbook's templates/, then the playbook directory. First match wins, so a file inside the role shadows one of the same name beside the playbook.
The same cascade applies to copy and script with the role's files/. That automatic resolution is one of the main practical benefits of the role layout — paths inside a role are bare filenames.
Q. What makes a role good enough to share?
One concern, describable in a sentence without "and". Every user-facing variable in defaults/, prefixed with the role name. Platform values in vars/. Idempotent — changed=0 on a second run. assert or argument_specs validating inputs. Handlers using listen. validate: on any config that can lock you out.
Plus a README documenting every variable with defaults, supported platforms and a working example — and ideally a Molecule scenario, which is Module 11.
Part E · Galaxy and distribution
E1 · Consuming roles
A requirements.yml without versions is "milk"; with pinned versions it is the full description. Unpinned, a colleague installing next month gets a different version of the role and the playbook breaks, with nothing in your repository having changed to explain why. It is the same lockfile discipline every other ecosystem learned the hard way, and interviewers ask about it because it separates people who have shipped from people who have demoed.
ansible-galaxy role install geerlingguy.nginx
ansible-galaxy role install geerlingguy.nginx,3.1.4 # pinned
ansible-galaxy role install -r requirements.yml # ⭐ the production way
ansible-galaxy role install -r requirements.yml -p ./galaxy_roles
ansible-galaxy role list
ansible-galaxy role remove geerlingguy.nginx# requirements.yml — roles AND collections in one file
---
roles:
- name: geerlingguy.nginx
version: "3.1.4" # ⭐ always pin
- name: internal.monitoring # from a private git repo
src: git+https://git.internal/ansible/monitoring.git
version: v2.1.0
scm: git
collections:
- name: community.general
version: "8.3.0"
- name: amazon.aws
version: "7.2.0"ansible-galaxy install -r requirements.yml # installs BOTH roles and collectionsThis is the same discipline as a lockfile in any other ecosystem, and unpinned dependencies is a legitimate answer to "what would you fix first in this repo?"
E2 · Roles vs collections — the distinction, restated
You met this in Module 01 Part A4. It matters again here.
| Unit | What it is |
|---|---|
| Role | Tasks, handlers, templates, files, defaults for one job. Cannot contain modules or plugins. ansible-galaxy role install |
| Collection | A superset — can contain modules, plugins, roles and playbooks, versioned with declared dependencies. ansible-galaxy collection install |
🎯 Interview questions — Galaxy and distribution
Q. How do you manage role and collection dependencies in a project?
A requirements.yml with pinned versions, committed to the repository and installed as a CI step with ansible-galaxy install -r requirements.yml — which handles both roles and collections from one file.
It supports private sources too: a git URL with scm: git and a tag as the version.
Without pinning, different people and different CI runs get different versions and the playbook breaks with nothing having changed in your repo.
Q. Would you use a Galaxy role from the internet in production?
With review. Read the tasks — you are granting it root on your fleet. Check maintenance activity, open issues, and whether it has tests. Pin an exact version and, in a regulated environment, mirror it internally rather than pulling from public Galaxy at run time.
Well-maintained roles such as geerlingguy.* save real time; an abandoned role with no tests is a liability you now own.
Q. Role or collection for new internal work?
A collection. It can hold roles, modules, plugins and playbooks together, is versioned as one unit, can declare dependencies, and installs with a single command.
Standalone roles remain valid and are everywhere in existing code, but for anything beyond a couple of roles — and certainly if you have custom filters or modules — a collection is the modern packaging answer.
Part F · Putting it together
F1 · Production practice
| Habit | Why |
|---|---|
| Everything user-facing in defaults/, never vars/ | vars/ is level 15 and silently beats inventory — the most expensive role bug there is |
| Prefix every variable with the role name | Role variables are global once the role runs; port from two roles collides |
| One concern per role — describable without "and" | Composability. A role that installs two things cannot be reused for one |
| meta/argument_specs.yml or an assert first task | Fails before the role acts, with a message naming the bad option |
| tags: always on include_vars in tasks/main.yml | Otherwise --tags runs skip the variable load and everything downstream fails |
| apply: when tagging an include_role | Without it the tag stops at the include and the role silently does nothing |
| listen: topics rather than handler names | Renaming a handler stops being a breaking change for consumers |
| Pin every version in requirements.yml | Unpinned means a different version for every teammate and every CI rebuild |
| Own roles in ./roles/, Galaxy roles in ./galaxy_roles/ (gitignored) | Makes it obvious what your team wrote versus what came from the internet |
| README with every variable, default, platform and an example | An undocumented role gets rewritten instead of reused |
| Prefer explicit composition in the playbook over meta dependencies | Deep dependency chains make ordering unpredictable and failures confusing |
| Second run must report changed=0 | The definition of a role that is safe to schedule |
F2 · Capstone exercise
Brief. Build a role app_deploy that:
- Works on both Debian and RedHat families, failing clearly on anything else
- Exposes app_version, app_port and app_user as overridable from group_vars
- Keeps package names and paths internal and non-overridable
- Validates that app_version is supplied and app_port is above 1024, before doing anything
- Deploys a config template that restarts the service only when it changes
- Can be invoked with --tags app_config and still work correctly
- Reports changed=0 on a second run
✅ Model answer — attempt it first, then click
roles/app_deploy/
defaults/main.yml
vars/Debian.yml
vars/RedHat.yml
meta/argument_specs.yml
tasks/main.yml
handlers/main.yml
templates/app.conf.j2# defaults/main.yml -- requirement 2: the public API
---
app_version: ""
app_port: 8080
app_user: appuser
app_config_dir: /etc/myapp# vars/Debian.yml -- requirement 3: internal, high precedence
---
app_package: myapp
app_service: myapp
app_python: python3# meta/argument_specs.yml -- requirement 4
---
argument_specs:
main:
short_description: Deploy the application
options:
app_version:
type: str
required: true
description: Version to deploy. No safe default exists
app_port:
type: int
default: 8080
app_user:
type: str
default: appuser# tasks/main.yml
---
- name: Load platform variables # requirement 1
ansible.builtin.include_vars: "{{ item }}"
with_first_found:
- "{{ ansible_facts['os_family'] }}.yml"
- default.yml
tags: always # requirement 6
- name: Validate platform and inputs # requirements 1 and 4
ansible.builtin.assert:
that:
- ansible_facts['os_family'] in ['Debian', 'RedHat']
- app_version | length > 0
- app_port | int > 1024
fail_msg: >-
app_deploy needs a supported OS, a non-empty app_version,
and app_port above 1024 (got {{ app_port }})
tags: always # requirement 6
- name: Ensure the service account exists
ansible.builtin.user:
name: "{{ app_user }}"
system: true
shell: /usr/sbin/nologin
tags: app_install
- name: Install the application package
ansible.builtin.package:
name: "{{ app_package }}-{{ app_version }}"
state: present
tags: app_install
- name: Ensure the config directory exists
ansible.builtin.file:
path: "{{ app_config_dir }}"
state: directory
owner: "{{ app_user }}"
mode: "0755"
tags: app_config
- name: Deploy the configuration # requirement 5
ansible.builtin.template:
src: app.conf.j2
dest: "{{ app_config_dir }}/app.conf"
owner: "{{ app_user }}"
mode: "0640"
notify: "restart app" # only fires on change
tags: app_config
- name: Ensure the service is running and enabled
ansible.builtin.service:
name: "{{ app_service }}"
state: started
enabled: true
tags: app_config# handlers/main.yml
---
- name: Restart the application
ansible.builtin.service:
name: "{{ app_service }}"
state: restarted
listen: "restart app"The six things most people miss:
- tags: always on BOTH the include_vars and the assert. Requirement 6 is the whole reason. Run with --tags app_config and without them, app_package is undefined and the validation never runs — so a bad app_port slips straight through to production.
- app_version in defaults/ as an empty string, plus a required: true in argument_specs and a length > 0 assert. There is no safe default for a version. Defaulting it to latest would deploy something nobody chose.
- Package name and service name in vars/, not defaults/. Requirement 3 — they are platform facts, not user choices, and belong at high precedence.
- notify on the template only, not on the file or package tasks. Requirement 5 says restart when the config changes, and the template task is the only one whose change means that.
- listen: "restart app" rather than notifying the handler by name — so renaming the handler later does not break consumers.
- | int on app_port in the assert. A value from -e arrives as a string, and "80" > 1024 compares a string with an integer. Module 02 Part D5.
Verify all seven:
ansible-playbook site.yml # FAILS - no app_version
ansible-playbook site.yml -e app_version=2.4.1 -e app_port=80 # FAILS - port too low
ansible-playbook site.yml -e app_version=2.4.1 --check --diff
ansible-playbook site.yml -e app_version=2.4.1
ansible-playbook site.yml -e app_version=2.4.1 # changed=0
ansible-playbook site.yml -e app_version=2.4.1 --tags app_configF3 · Command reference — everything from this module
Creating and inspecting roles
ansible-galaxy role init roles/myrole # ⭐ scaffold the standard layout
ansible-galaxy role init --offline roles/myrole # no Galaxy API call
ansible-galaxy role list # ⭐ what roles are installed
ansible-galaxy role info geerlingguy.nginx # metadata before you trust it
ansible-doc -t role -l # roles with argument_specs
ansible-doc -t role myrole # ⭐ rendered argument_specs
find roles/myrole -type f | sort # what does this role containInstalling roles and collections
ansible-galaxy role install geerlingguy.nginx
ansible-galaxy role install geerlingguy.nginx,3.1.4 # ⭐ pinned
ansible-galaxy role install -r requirements.yml # ⭐ roles only
ansible-galaxy collection install -r requirements.yml # ⭐ collections only
ansible-galaxy install -r requirements.yml # ⭐ BOTH from one file
ansible-galaxy install -r requirements.yml --force # re-install / upgrade
ansible-galaxy role install -r requirements.yml -p ./galaxy_roles
ansible-galaxy role remove geerlingguy.nginxBuilding and publishing a collection
ansible-galaxy collection init mycompany.platform # ⭐ scaffold
ansible-galaxy collection build # produce the .tar.gz
ansible-galaxy collection install mycompany-platform-1.0.0.tar.gz -p ./collections
ansible-galaxy collection publish mycompany-platform-1.0.0.tar.gzRunning roles
ansible-playbook site.yml --list-tasks # ⭐ role tasks appear if imported
ansible-playbook site.yml --tags app_config # ⭐ needs apply: on include_role
ansible-playbook site.yml --start-at-task "app_deploy : Deploy the configuration"
ansible-playbook site.yml -e app_version=2.4.1 # ⭐ role params from the CLI
ansible-playbook site.yml --check --diff --limit web01 # ⭐ always before prodRole keywords quick reference
# INVOCATION
roles: # ⭐ static, runs BEFORE tasks:
- common
- role: nginx # ⭐ with parameters (precedence 20)
vars: { nginx_port: 8080 }
- role: monitoring
when: enable_monitoring | bool # applied to EVERY task in the role
tags: [monitoring] # inherited by every task in the role
- ansible.builtin.import_role: # ⭐ static, runs where placed
name: nginx
- ansible.builtin.include_role: # ⭐ dynamic - variable names, loops
name: "{{ role_name }}"
apply: # ⭐ REQUIRED to push tags down
tags: [nginx]
tags: [nginx] # ⭐ AND on the include itself
# INSIDE A ROLE
- ansible.builtin.include_vars: "{{ item }}" # ⭐ platform variables
with_first_found:
- "{{ ansible_facts['os_family'] }}.yml"
- default.yml
tags: always # ⭐ or --tags breaks everything
- ansible.builtin.import_tasks: install.yml # ⭐ bare filename, relative to tasks/
notify: "restart app" # ⭐ a listen: topic, not a handler name# meta/main.yml
dependencies:
- role: common # runs BEFORE this role's tasks
allow_duplicates: false # the default - dedupes identical invocations□ Everything user-facing is in defaults/, prefixed with the role name
□ Platform values are in vars/<os_family>.yml, loaded with tags: always
□ argument_specs.yml or an assert validates inputs before anything runs
□ Handlers use listen: topics
□ validate: on any config that can lock you out
□ Second run reports changed=0
□ README documents every variable, default, platform and an exampleF4 · Official documentation
| Link | Covers |
|---|---|
| Roles | Directory structure, invocation, dependencies, allow_duplicates |
| Re-using files and roles | The static/dynamic distinction across tasks and roles |
| Role argument validation | meta/argument_specs.yml in full |
| Handlers | listen: topics and handler scope in roles |
| Variable precedence | Why defaults/ is level 2 and vars/ is level 15 |
| Galaxy user guide | Installing roles and collections, requirements.yml syntax |
| Developing collections | Packaging roles inside a collection |
| Ansible Galaxy | The public registry itself |
| Sample directory layout | Official guidance on structuring a whole repository |
F5 · Self-assessment
1. Name the role directories and the magic filename.
tasks/, handlers/, defaults/, vars/, templates/, files/, meta/, library/, tests/. All optional.
main.yml is loaded automatically in each — any other filename is ignored unless explicitly included.
2. defaults/ versus vars/ — precedence and consequence.
defaults/ is level 2, the lowest — designed to be overridden, and therefore the role's public API. vars/ is level 15, above inventory, group_vars and play vars.
The consequence: a user-facing setting placed in vars/ cannot be overridden from inventory, silently, which produces hours of debugging on a configuration that is actually correct.
3. Why must role variables be prefixed?
They are not scoped to the role — once it runs, its variables are in the play's variable space. Two roles defining port collide, and the winner depends on invocation order, so the bug appears only when a second role is added.
4. roles: vs import_role vs include_role — three differences.
roles: runs before tasks:; the other two run where placed. Only include_role accepts a variable name or a loop. Only the static forms are visible to --list-tasks.
Plus: static forms expose role variables to the whole play, include_role scopes them; and tags on include_role need apply: to reach the inner tasks.
5. --tags nginx runs the include but nothing inside. Explain and fix.
The tag applied only to the include_role task; the role's inner tasks are untagged and were skipped. It fails silently — the play reports success while doing nothing.
Fix with apply: tags: [nginx] to push the tag down, and keep the tag on the include itself so the include is not skipped first.
6. How do role dependencies behave, and why avoid them?
Declared in meta/main.yml, they run before the role's own tasks and are deduplicated — once per play unless parameters differ or allow_duplicates: true.
Many teams avoid them because deep chains make ordering unpredictable and a dependency failure takes down a role that looks unrelated. Explicit composition in the playbook is easier to review and debug.
7. How do you support several distributions in one role?
vars/<os_family>.yml files loaded via include_vars with with_first_found, cascading from most specific to most general, tagged always. Plus an assert that fails clearly on unsupported platforms, and portable modules like package where possible.
8. What is listen: and why does it matter in a shared role?
It lets handlers subscribe to a topic instead of being notified by name, and several handlers can share a topic.
In a shared role it decouples consumers from your handler names, so renaming a handler is not a breaking change — and a task in one role can trigger handlers in another without knowing their names.
9. Why does tags: always belong on include_vars inside a role?
Because a tagged run skips every untagged task, including the variable load — so every subsequent task fails on undefined variables, and the error looks like a variable bug rather than a tagging bug.
The same applies to any assert you rely on for safety.
10. What is argument_specs and what does it give you?
meta/argument_specs.yml, since 2.11. Declares each role option's type, default, choices and whether it is required, validated before the role runs with a precise error naming the bad option.
It doubles as machine-readable documentation rendered by ansible-doc -t role <name>.
11. Why pin versions in requirements.yml?
Unpinned, ansible-galaxy installs whatever is newest at install time — so a teammate or a CI rebuild next month gets a different version and the playbook breaks with nothing having changed in your repository. It is the same discipline as a lockfile.
You have now built roles worth sharing. Module 06 covers packaging them properly — collection layout, galaxy.yml, versioning, and how a real team structures an Ansible repository.
📚 Sources for the interview questions
Behaviour verified against the current official Ansible roles documentation.
Question selection cross-referenced against publicly published 2026 Ansible interview question sets:
- Spacelift — 50+ Top Ansible Interview Questions & Answers for 2026
- GeeksforGeeks — Top 50+ Ansible Interview Questions and Answers
- Vinsys — Top 30 Ansible Interview Questions and Answers 2026
- K21 Academy — Ansible Interview Questions & Answers 2026
- Hirist — Top 25+ Ansible Interview Questions and Answers 2026
Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.