AnsibleXXII · Roles and ReuseRoles and reuse
defaults/ is the interface, vars/ is not
What you'll learn
- Treat a role as an interface with a public and a private half
- Place a value in defaults/, vars/ or a task deliberately, and justify it
- Recognise the symptom of a role whose interface was never designed
- Write the minimum documentation a caller actually needs
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Part XIII lesson 5 established the mechanism: defaults/main.yml sits at
entry 2 of the precedence ladder and vars/main.yml at entry 15,
thirteen levels apart with the entire inventory in between. That lesson owns
the precedence consequence and it is worth re-reading before this one.
This lesson is about the design decision that mechanism forces on you, which
is a different subject. A role is an interface. defaults/main.yml is
where that interface is written down. Everything else in the role is
implementation, and the difference is not filing — it is a contract with
every future caller you will never meet.
The two halves of a role
Split every value a role touches into two piles:
Public — a caller may reasonably want a different one. Ports, paths,
package versions, timeouts, feature flags, worker counts, log levels, the
list of virtual hosts. These go in defaults/main.yml, one per line, with a
comment saying what the value means and what changing it costs.
Private — a different value would break the role, or the value is derived
from something the role already knows. Lookup tables the templates consume,
platform maps keyed on ansible_facts['os_family'], safety floors. These go
in vars/main.yml, or are computed with set_fact inside the role.
The pile a value lands in is a promise. defaults/ says you may change
this. vars/ says you may not change this from the inventory.
roles/webapp/
├── defaults/main.yml ← the public interface. Read this to use the role.
├── vars/main.yml ← implementation. Read this to modify the role.
├── tasks/main.yml
├── handlers/main.yml
├── templates/
├── files/
└── meta/main.yml
The dividing line is the file boundary, and nothing enforces it except the person writing the role. That is why it slips.
What a well-designed defaults file looks like
# roles/webapp/defaults/main.yml
#
# Everything in this file is part of the role interface. Callers may set any
# of it from group_vars, host_vars, play vars or a role parameter. Removing
# a key or changing its meaning is a breaking change.
# --- Service identity -------------------------------------------------
webapp_service_name: webapp
webapp_user: webapp
webapp_group: webapp
# --- Network ----------------------------------------------------------
# Port the application binds. Below 1024 requires the service unit to grant
# CAP_NET_BIND_SERVICE; the role does not do that for you.
webapp_listen_port: 8080
webapp_listen_address: 127.0.0.1
# --- Sizing -----------------------------------------------------------
# Worker processes. Default is deliberately conservative rather than
# ansible_facts['processor_vcpus'], so a run on an oversized host does not
# quietly consume it.
webapp_workers: 4
# --- Behaviour --------------------------------------------------------
# When false the role configures the service but does not start or enable it.
# Useful for image builds, where PID 1 is not systemd.
webapp_manage_service: true
Three properties make that file an interface rather than a bag of values:
- Every key is namespaced with the role name. A role that sets
port:orworkers:is colliding with every other role in the play — see lesson 7 for the same problem in handler names, and Part XIII lesson 7 for the naming discipline. - Every non-obvious default has a comment stating the trade-off, not
restating the key name.
webapp_workers: 4 # number of workersis worthless; the comment above earns its line. - The defaults are safe on a host you know nothing about. A default that
assumes a 64-core machine, or a default of
webapp_listen_address: 0.0.0.0, turns a first run into an incident.
The failure this prevents
A role whose tunables live in vars/ produces a specific, recognisable
estate.
The operator needs a different worker count for the two large hosts. They add
it to group_vars/web_large.yml. Nothing changes — vars/ outranks every
inventory source, so the line is inert. Confirmed by the debug output: the
override is in the inventory and the role reports its own value.
$ ansible-playbook -i inventory site.yml --tags webapp_debugTASK [webapp : report effective configuration] *********************************
ok: [web-01.example.com] => {
"msg": "workers=4 port=8080"
}So the operator escalates. First to -e webapp_workers=16 on the command
line, because extra vars beat everything. Then, when that has to be repeated
on every run and nobody remembers it, into a wrapper script. Then a second
wrapper for the other environment.
What has happened is that the role’s configuration moved out of the repository and into shell history. The property that made the estate reviewable — the repository describes what will happen — is gone, and the cause was one value in the wrong file.
Where the third option goes
Not every value belongs in either file. Some are derived, and a derived value in a static file is a value that will go stale:
# roles/webapp/tasks/main.yml
- name: derive the config path from the service name
ansible.builtin.set_fact:
webapp_config_path: "/etc/{{ webapp_service_name }}/app.conf"
That belongs in a task, not in defaults/, because putting it in defaults/
invites a caller to override webapp_service_name and get a config path that
no longer matches. A derived value computed at run time cannot drift away
from the thing it derives from.
The rule that follows: defaults/ holds inputs, not conclusions. If the
value is a function of other values, compute it.
Documenting the interface
A role’s README needs less than people write and more than people write. Four things, and nothing else is load-bearing:
| Section | Why a caller needs it |
|---|---|
| One sentence saying what the role does | The naming test from lesson 2, written down |
Every defaults/ key, its meaning and its trade-off | This is the interface; the file itself is the source |
| What the role requires that it does not install | The unstated dependencies from lesson 2, stated |
| What it restarts, and when | Blast radius: which service goes down on a change |
The last row is the one that gets omitted and the one an operator reads at 03:00. A role that restarts nginx when a template changes should say so in one line, because the person deciding whether to run it during business hours needs exactly that fact.
Knowledge check
Knowledge check · 4 questions
Q1. What makes defaults/main.yml the only viable home for a role tunable, as opposed to any other variable location?
Q2. A role author has a value that is computed from another value the caller sets - a config path derived from a service name. Which placements are defensible? Select all that apply.
Q3. Giving a required input a plausible-looking default, such as webapp_database_host defaulting to localhost, is preferable to leaving it undefined, because the role then always runs.
Q4. An estate has a playbook wrapped in a shell script that passes six -e overrides on every run. What does that most reliably indicate?
Passing score: 75%. Answers are checked in this browser.