Skip to main content
RunBook Academy

AnsibleXXII · Roles and ReuseRoles and reuse

defaults/ is the interface, vars/ is not

Intermediate⏱ ~19 minansible-playbook

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

Not yet marked complete on this device.

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:

  1. Every key is namespaced with the role name. A role that sets port: or workers: 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.
  2. Every non-obvious default has a comment stating the trade-off, not restating the key name. webapp_workers: 4 # number of workers is worthless; the comment above earns its line.
  3. 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.

Read-only / Safethe inert override
$ ansible-playbook -i inventory site.yml --tags webapp_debug
TASK [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:

SectionWhy a caller needs it
One sentence saying what the role doesThe naming test from lesson 2, written down
Every defaults/ key, its meaning and its trade-offThis is the interface; the file itself is the source
What the role requires that it does not installThe unstated dependencies from lesson 2, stated
What it restarts, and whenBlast 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

  1. Q1. What makes defaults/main.yml the only viable home for a role tunable, as opposed to any other variable location?

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

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

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