Module 05 — Roles & Reusability

Updated 20 August 2026

Module 05 · Roles & Reusability

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

The analogy. Think of flat-pack furniture. A wardrobe arrives as one box, and inside are the panels, the screws, the little hex key and an instruction booklet — everything the wardrobe needs, and nothing belonging to the bookshelf. You can carry that box to a completely different house and it still works, because it never assumed anything about the room.

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:

One 800-line site.yml

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.

Four roles, one thin site.yml

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.

yaml
# site.yml becomes this thin
---
- name: Configure web tier
  hosts: web
  become: true
  roles:
    - common
    - nginx
    - monitoring

A2 · The directory structure

The analogy. Think of how a kitchen is laid out. Knives live in a knife drawer, spices in a rack, pans in a low cupboard. Nobody writes any of this down, and yet you can walk into a stranger's kitchen and find the cutlery in about four seconds. That shared convention is what lets you cook in someone else's house without a guided tour.

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.

plain text
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 team
Every directory is optional, and main.yml is the magic filename. Ansible automatically loads tasks/main.yml, handlers/main.yml, defaults/main.yml, vars/main.yml and meta/main.yml. Any other filename in those directories is ignored unless you explicitly include_tasks it.

So 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

plain text
roles/nginx/tasks/
  main.yml           <- imports the others, in order
  install.yml
  configure.yml
  service.yml
yaml
# roles/nginx/tasks/main.yml
---
- ansible.builtin.import_tasks: install.yml
- ansible.builtin.import_tasks: configure.yml
- ansible.builtin.import_tasks: service.yml
Note the paths are bare filenames. Inside a role, import_tasks: install.yml resolves relative to tasks/. The same convenience applies to template: src=nginx.conf.j2 finding templates/nginx.conf.j2, and copy: src=index.html finding files/index.html. That path resolution is one of the main practical benefits of the role layout.

A3 · 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
plain text
# ansible.cfg - make it explicit rather than relying on defaults
[defaults]
roles_path = ./roles:./galaxy_roles
The convention worth adopting: keep your own roles in ./roles/ and let Galaxy-installed roles land somewhere separate such as ./galaxy_roles/, which is gitignored. Mixing them means you cannot tell at a glance what your team wrote and what came from the internet — and you risk committing a downloaded role you meant to pin in requirements.yml.
🧪 Exercise A3.1 — Scaffold a role and see what you get
bash
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
plain text
- Role roles/nginx was created successfully
plain text
roles/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
yaml
---
# tasks file for roles/nginx
yaml
galaxy_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:

  1. 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.
  2. 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

The analogy. Think of two kinds of price tag in a shop. One is the recommended retail price printed on the box — a starting point, and everybody understands it can be negotiated down. The other is a "fixed price, no haggling" sign taped to the till, which overrides whatever the customer had in mind.

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.

defaults/main.yml — precedence 2

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.

vars/main.yml — precedence 15

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.

yaml
# 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: nginx
The failure this causes in real teams. Someone puts nginx_port in vars/main.yml. A colleague sets nginx_port: 8080 in group_vars/web.yml and it is silently ignored — level 15 beats level 6, with no warning and no error.

Hours 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

The analogy. Think of the shared office fridge. You put in a lunchbox labelled "lunch". So does everybody else. By Thursday there are six identical boxes, nobody can safely open any of them, and somebody's food gets eaten by mistake. The fix everyone arrives at eventually is to write your name on the lid.

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.

yaml
# ❌ 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: true
Role variables are not scoped to the role. Once a role runs, its variables are in the play's variable space. Two roles that both define port will collide, and which one wins depends on invocation order — producing a bug that appears only when someone adds a second role.

Prefix 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

The analogy. Think of a form with a dropdown instead of a blank box. A blank box lets someone write "next Tuesday-ish" in a date field, and the problem only surfaces three departments and two weeks later, by which time nobody remembers who filled it in. A dropdown of valid choices, marked required, catches it at the counter — immediately, and with a message saying exactly what was wrong.

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.

yaml
# 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]
What this buys you. Ansible validates types, applies defaults, enforces choices, and fails before the role runs with a precise message naming the offending option. It also doubles as machine-readable documentation — ansible-doc -t role nginx renders it.

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
bash
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
plain text
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 analogy. Think of how a job gets onto your day. Some things were booked into the diary before the day even started, and they happen first, ahead of whatever else turns up. Some you write into a specific slot in advance, so anyone looking at your diary can see it coming. And some you decide on the spot when you get there — flexible, because you can choose based on the weather, but until that moment your diary looks empty.

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.

yaml
# 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_roleinclude_role
When processedParse timeParse timeRun time
RunsBefore tasks:Exactly where placedExactly 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 inheritanceInherited by all tasksInherited by all tasksOnly on the include — needs apply:
Two differences that cause real bugs:

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

yaml
# 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 tag

C2 · Role parameters and conditional roles

yaml
- 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]
Role parameters sit at precedence level 20 — above set_fact, above task vars, above everything except include params and -e. That makes them powerful and occasionally surprising: a parameter passed at the call site will beat a value the role's own tasks compute with set_fact.

C3 · Dependencies in meta/main.yml

yaml
# roles/nginx/meta/main.yml
---
dependencies:
  - role: common
  - role: firewall
    vars:
      firewall_allowed_ports: [80, 443]

allow_duplicates: false        # the default
Three things about dependencies that catch people out:
  1. They run before the role's own tasks — always, and before the role even begins.
  2. 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.
  3. 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
yaml
---
- 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]
bash
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:

plain text
TASK [demo : ansible.builtin.debug] ***   ok: [localhost] => {"msg": [...]}

--tags included — the trap:

plain text
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=0

Read 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:

plain text
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

The test: can you describe what the role does in one sentence without using "and"?

"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

The analogy. Think of a travel adapter. The appliance you are carrying is the same everywhere — same laptop, same charger, same job. The plug is not: the UK, Europe and the US each want a different shape. So you carry one device and a small pouch of adapters, and pick the right one after you land.

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:

plain text
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
yaml
# 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.yml
with_first_found gives you a cascade — most specific first, falling back to more general. Ubuntu-22.yml, then Ubuntu.yml, then Debian.yml, then default.yml. That is how a role supports a family generally while special-casing one release.

tags: 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

The analogy. Think of a fire drill announcement. It does not say "Ahmed, Sara and Priya, please leave the building" — it says "evacuate", and everyone trained to respond to that word responds. Which means people can join the company or leave it without anybody rewriting the announcement.

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.

yaml
# 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"
yaml
# 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 fire
listen: decouples the notifier from handler names, which matters a lot in roles. A task in role A can notify a topic that role B's handlers subscribe to, without either role knowing the other's handler names.

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

Handler scope gotcha: handlers from a role invoked with include_role are only available after the include has run. A task earlier in the play cannot notify them. With roles: or import_role, handlers are registered at parse time and available throughout the play.

D4 · files/ and templates/ resolution

Inside a role, copy and template search automatically:

yaml
- 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.html

The 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

In a team, an undocumented role gets rewritten rather than reused — which defeats the entire purpose of writing it. The README must contain:
  • 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
bash
ansible-galaxy role init roles/webserver
yaml
# roles/webserver/defaults/main.yml
---
webserver_port: 80
webserver_server_name: "_"
webserver_enable_tls: false
webserver_document_root: /var/www/html
yaml
# roles/webserver/vars/Debian.yml
---
webserver_package: nginx
webserver_service: nginx
webserver_config_path: /etc/nginx/sites-available/default
webserver_user: www-data
yaml
# roles/webserver/vars/RedHat.yml
---
webserver_package: nginx
webserver_service: nginx
webserver_config_path: /etc/nginx/conf.d/default.conf
webserver_user: nginx
yaml
# 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
yaml
# 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
plain text
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=3

Second run:

plain text
PLAY RECAP: web01 : ok=5  changed=0

Seven decisions, each worth being able to justify:

  1. Everything user-facing in defaults/ — port, server name, TLS flag, document root. All overridable from group_vars.
  2. Everything platform-internal in vars/<os_family>.yml — package name, service name, config path. Not meant to be overridden, and correctly at high precedence.
  3. webserver_ prefix on every variable — no collision with another role's port or service.
  4. tags: always on the variable load — otherwise --tags configure breaks everything downstream.
  5. assert for platform support — fails in two seconds with a clear message rather than a confusing "package not found" four tasks later.
  6. validate: on the template — a broken nginx config that reaches disk and triggers a reload takes the site down.
  7. 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

The analogy. Think of a shopping list that names the brand and the size. "Milk" gets you whatever happened to be on the shelf that day. "Two litres, semi-skimmed, that brand" gets you the same thing every time — and the recipe tastes the same in June as it did in March.

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.

bash
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
yaml
# 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"
bash
ansible-galaxy install -r requirements.yml            # installs BOTH roles and collections
Always pin versions. Without a version, ansible-galaxy takes whatever is newest at install time. A teammate installing next month, or a CI runner rebuilding its cache, gets a different version — and the playbook breaks for reasons that look inexplicable because nothing in your repository changed.

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

UnitWhat it is
RoleTasks, handlers, templates, files, defaults for one job. Cannot contain modules or plugins. ansible-galaxy role install
CollectionA superset — can contain modules, plugins, roles and playbooks, versioned with declared dependencies. ansible-galaxy collection install
For new internal work, package roles inside a collection. mycompany.platform containing roles/webserver, roles/monitoring plus any custom filters or modules is versioned as a unit and installs with one command. Standalone roles are the older distribution model and still perfectly valid, but a collection is the modern answer when you have more than a couple of roles or any custom plugins.

🎯 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

HabitWhy
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 nameRole 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 taskFails before the role acts, with a message naming the bad option
tags: always on include_vars in tasks/main.ymlOtherwise --tags runs skip the variable load and everything downstream fails
apply: when tagging an include_roleWithout it the tag stops at the include and the role silently does nothing
listen: topics rather than handler namesRenaming a handler stops being a breaking change for consumers
Pin every version in requirements.ymlUnpinned 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 exampleAn undocumented role gets rewritten instead of reused
Prefer explicit composition in the playbook over meta dependenciesDeep dependency chains make ordering unpredictable and failures confusing
Second run must report changed=0The definition of a role that is safe to schedule

F2 · Capstone exercise

Attempt this without looking anything up. It exercises role structure, variable placement, cross-platform support, handlers, tags and idempotency together.

Brief. Build a role app_deploy that:

  1. Works on both Debian and RedHat families, failing clearly on anything else
  2. Exposes app_version, app_port and app_user as overridable from group_vars
  3. Keeps package names and paths internal and non-overridable
  4. Validates that app_version is supplied and app_port is above 1024, before doing anything
  5. Deploys a config template that restarts the service only when it changes
  6. Can be invoked with --tags app_config and still work correctly
  7. Reports changed=0 on a second run
Model answer — attempt it first, then click
plain text
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
yaml
# defaults/main.yml  -- requirement 2: the public API
---
app_version: ""
app_port: 8080
app_user: appuser
app_config_dir: /etc/myapp
yaml
# vars/Debian.yml  -- requirement 3: internal, high precedence
---
app_package: myapp
app_service: myapp
app_python: python3
yaml
# 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
yaml
# 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
yaml
# handlers/main.yml
---
- name: Restart the application
  ansible.builtin.service:
    name: "{{ app_service }}"
    state: restarted
  listen: "restart app"

The six things most people miss:

  1. 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.
  2. 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.
  3. Package name and service name in vars/, not defaults/. Requirement 3 — they are platform facts, not user choices, and belong at high precedence.
  4. 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.
  5. listen: "restart app" rather than notifying the handler by name — so renaming the handler later does not break consumers.
  6. | 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:

bash
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_config

F3 · Command reference — everything from this module

Commands and role keywords from Module 05. ⭐ marks genuinely daily-use.

Creating and inspecting roles

bash
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 contain

Installing roles and collections

bash
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.nginx

Building and publishing a collection

bash
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.gz

Running roles

bash
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 prod

Role keywords quick reference

yaml
# 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
yaml
# meta/main.yml
dependencies:
  - role: common                        # runs BEFORE this role's tasks
allow_duplicates: false                 # the default - dedupes identical invocations
The role-authoring checklist, worth running through before you share one:
plain text
□ 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 example

F4 · Official documentation

LinkCovers
RolesDirectory structure, invocation, dependencies, allow_duplicates
Re-using files and rolesThe static/dynamic distinction across tasks and roles
Role argument validationmeta/argument_specs.yml in full
Handlerslisten: topics and handler scope in roles
Variable precedenceWhy defaults/ is level 2 and vars/ is level 15
Galaxy user guideInstalling roles and collections, requirements.yml syntax
Developing collectionsPackaging roles inside a collection
Ansible GalaxyThe public registry itself
Sample directory layoutOfficial 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.


Next — Module 06 · Collections & Content Structure.

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:

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.

Spotted a mistake or want something added? Send me a note.