Ansible basics · Lesson 7 of 12 · about 20 minutes

Roles: playbooks you can reuse

site.yml had grown to 97 lines, with the baseline, nginx and their templates side by side. This lesson splits it into two roles, cuts site.yml to 15 lines, and proves the split changed nothing on the servers. Then the catch: a value moved into a role's defaults vanished from the health check, which failed on web1. Where a variable lives decides who can see it.

By the end you'll know

  • What a role is, and which folders Ansible looks in
  • How to turn a playbook's tasks, handlers and templates into roles
  • How to prove a refactor changed nothing, with --check and a real run
  • What role defaults are for, and why they're the easiest variables to override
  • Why a variable in a role's defaults isn't visible to other playbooks
  • The difference between a role's defaults and its vars

Before you start

  • You need: Lessons 1 to 6 done, with the three servers running and site.yml applied.
  • Get the new files: in ansible-practice, git pull. You'll have a roles folder with baseline and nginx in it, a 15-line site.yml, and no top-level templates folder: the templates moved into the nginx role.
  • Where commands run: your own computer's terminal, in ansible-practice.

The short version

  • The split. Everything used to live in one long file, site.yml. This lesson moves the work into two folders called roles: baseline, the setup every server gets, and nginx, the web server setup. site.yml shrinks to a few lines that say which role goes where.
  • How you know nothing broke. You run it, and every server reports changed=0: nothing on them had to change, so the new layout does exactly what the old one did.
  • The catch. The web port moved into the nginx role's own settings. site.yml uses that role, so it could still see the port. The health check from Lesson 6 doesn't use the role, so on web1 the port no longer existed, and the check failed.
  • The rule. A role's settings are like notes inside its folder: only playbooks that open the folder can read them. The inventory is a whiteboard on the wall that every playbook can read. Anything two playbooks both need goes on the whiteboard.
  • Weak and strong settings. A role's defaults are weak: anything you set elsewhere wins, which is what you usually want. Its vars are strong, and beat what the inventory says. Settings people are meant to change go in defaults.

A playbook that grew

After six lessons, site.yml was 97 lines: the baseline play, the web play, a handler, and a templates folder beside it that only the web play used. It works, but it's one long file, and nothing in it can be reused. If the bookshop opened a second shop with its own servers, the only way to give them the same baseline would be to copy and paste it.

A role is a folder that holds everything for one job: its tasks, its handlers, its templates, and its default settings, each in a known place. Think of a recipe card in a box. The card says what to make and how; the shopping list and the oven setting are on the card too. Anyone can take the card out and cook it, in any kitchen, and the box stays tidy.

The shape of a role

ansible-galaxy, which comes with Ansible, can make an empty role to look at:

BASH
ansible-galaxy role init --init-path /tmp demo_role
(cd /tmp && find demo_role | sort)
OUTPUT
- Role demo_role was created successfully

demo_role
demo_role/defaults
demo_role/defaults/main.yml
demo_role/files
demo_role/handlers
demo_role/handlers/main.yml
demo_role/meta
demo_role/meta/main.yml
demo_role/README.md
demo_role/tasks
demo_role/tasks/main.yml
demo_role/templates
demo_role/tests
demo_role/tests/inventory
demo_role/tests/test.yml
demo_role/vars
demo_role/vars/main.yml

(find lists everything in the new folder. rm -rf /tmp/demo_role tidies up afterwards.) Each folder has one job, and Ansible knows where to look:

  • tasks/main.yml: the role's tasks, run in order. The only part a role must have.
  • handlers/main.yml: its handlers.
  • templates/ and files/: where its template and copy tasks find their source files, without a path.
  • defaults/main.yml: its settings, at the lowest priority of any variable, so anyone using the role can override them.
  • vars/main.yml: settings at a high priority, which the inventory can't override. More on that in Spot the problem.
  • meta/main.yml: information about the role, including other roles it depends on.

You don't need every folder. The bookshop's roles use only the ones they need.

The bookshop's two roles

OUTPUT
roles/baseline/defaults/main.yml
roles/baseline/tasks/main.yml
roles/nginx/defaults/main.yml
roles/nginx/handlers/main.yml
roles/nginx/tasks/main.yml
roles/nginx/templates/bookshop.conf.j2
roles/nginx/templates/index.html.j2
  • baseline is the first play's tasks: the packages, the login message, the staff accounts, and the first-setup record.
  • nginx is the web play's tasks, the Reload nginx handler, and the two templates, moved from the top-level templates folder.

The tasks moved across unchanged, apart from three small things. In a role, tasks/main.yml is just the list of tasks, with no hosts or tasks: above them. The template tasks now say src: bookshop.conf.j2, with no folder, because a role looks in its own templates/. And the package list moved out of the task into the baseline role's defaults:

YAML
# roles/baseline/defaults/main.yml
# Packages every server gets.
baseline_packages:
  - acl
  - htop
  - tree

# Staff accounts to manage. Empty unless the inventory says otherwise;
# the bookshop's list is in group_vars/all.yml.
bookshop_staff: []
YAML
# roles/baseline/tasks/main.yml (the first task)
- name: Install the packages every server needs
  ansible.builtin.apt:
    name: "{{ baseline_packages }}"
    state: present
    update_cache: true
    cache_valid_time: 3600

bookshop_staff: [] is a safety net. The real list is still in group_vars/all.yml, and that wins. But if someone used this role with an inventory that had no staff list, the loop would run over an empty list and do nothing, rather than stop with an error.

And site.yml is now this:

YAML
# The bookshop servers, described. Run with:  ansible-playbook site.yml
# Dry run first:                              ansible-playbook site.yml --check --diff
# The work is in roles/: baseline for every server, nginx for the web servers.

- name: Baseline for every bookshop server
  hosts: bookshop
  become: true
  roles:
    - baseline

- name: Web servers
  hosts: web
  become: true
  roles:
    - nginx

From 97 lines to 15. site.yml now says only what goes where: the baseline on every bookshop server, nginx on the web servers. How each one is done is in its role. roles: runs a role's tasks as the play's tasks, and Ansible finds the role in the roles folder next to the playbook.

BASH
ansible-playbook site.yml --list-tasks
OUTPUT
  play #1 (bookshop): Baseline for every bookshop server	TAGS: []
    tasks:
      baseline : Install the packages every server needs	TAGS: []
      baseline : Show who manages this server when someone logs in	TAGS: []
      baseline : Staff accounts	TAGS: []
      baseline : Record when this server was first set up	TAGS: []

  play #2 (web): Web servers	TAGS: []
    tasks:
      nginx : Install nginx	TAGS: []
      nginx : Remove nginx's default site	TAGS: []
      nginx : Write the bookshop site's config	TAGS: []
      nginx : Enable the bookshop site	TAGS: []
      nginx : Check nginx's config before anything reloads it	TAGS: []
      nginx : Keep nginx running, and start it at boot	TAGS: []
      nginx : Put up the bookshop's home page	TAGS: []

The same eleven tasks the two plays had, now with the role's name in front, so the output says where each task comes from.

A refactor should change nothing

Moving code around without changing what it does is a refactor. The test of a refactor is simple: run it, and nothing on the servers should change. A dry run first:

BASH
ansible-playbook site.yml --check --diff
OUTPUT
PLAY RECAP *********************************************************************
db1                        : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web1                       : ok=12   changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   
web2                       : ok=12   changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   

No diffs, and changed=0 everywhere. The skipped=1 on each web server is the nginx -t task: the command module doesn't run commands in check mode, since it can't know what they'd do. Then the real run:

BASH
ansible-playbook site.yml
OUTPUT
PLAY [Baseline for every bookshop server] **************************************
...
TASK [baseline : Staff accounts] ***********************************************
ok: [web2] => (item=deploy)
ok: [web1] => (item=deploy)
ok: [db1] => (item=deploy)
...
PLAY [Web servers] *************************************************************
...
TASK [nginx : Write the bookshop site's config] ********************************
ok: [web1]
ok: [web2]
...
PLAY RECAP *********************************************************************
db1                        : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web1                       : ok=13   changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web2                       : ok=13   changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

The same counts as before the split, and changed=0. The templates, from their new folder, produced exactly the files already on the servers. Then the health check from Lesson 6, to be sure:

BASH
ansible-playbook health.yml
OUTPUT
PLAY RECAP *********************************************************************
web1                       : ok=1    changed=0    unreachable=0    failed=1    skipped=0    rescued=0    ignored=0   
web2                       : ok=8    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   

web1 failed, on the first task. site.yml was happy; the health check wasn't.

The variable that disappeared

OUTPUT
TASK [Ask nginx for the home page, retrying while it starts] *******************
fatal: [web1]: FAILED! => {"msg": "The task includes an option with an undefined variable.. 'http_port' is undefined\n\nThe error appears to be in '.../health.yml': line 7, column 7, ..."}
ok: [web2]

'http_port' is undefined, on web1 only. Here's what happened. Before this lesson, http_port: 80 and site_name: bookshop were in group_vars/web.yml. Since only nginx uses them, it seemed tidy to move them into the nginx role's defaults, and delete group_vars/web.yml:

YAML
# roles/nginx/defaults/main.yml
# Defaults for the nginx role: the lowest priority of any variable.
# group_vars, host_vars, the inventory and -e all win over these.
http_port: 80
site_name: bookshop

site.yml uses the nginx role, so it saw the default, and worked. But a role's defaults only exist in plays that use the role. health.yml doesn't use it. On web1, nothing else set http_port, so in health.yml it didn't exist. web2 was fine because its 8080 comes from the inventory, which every playbook sees.

The clue was there before anything ran. ansible-inventory --host shows the variables the inventory gives a server, which is what every playbook can see:

BASH
ansible-inventory --host web1
ansible-inventory --host web2
OUTPUT
{
    "admin_email": "ops@bookshop.example",
    "bookshop_staff": [
        ...
    ]
}
{
    "admin_email": "ops@bookshop.example",
    "bookshop_staff": [
        ...
    ],
    "http_port": 8080
}

No http_port for web1. (Role defaults never appear here: they're not part of the inventory.)

The fix is a rule worth keeping: a value that more than one playbook needs belongs in the inventory. The role default stays, as the fallback for anyone who uses the role without setting it. So http_port goes back in group_vars/web.yml:

YAML
# Settings for the web servers, shared by every playbook that runs on them.
# The nginx role has its own default of 80; this is what health.yml reads.
http_port: 80
BASH
ansible-inventory --host web1 | grep http_port
ansible-playbook health.yml
ansible-playbook site.yml
OUTPUT
    "http_port": 80

PLAY RECAP *********************************************************************
web1                       : ok=8    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   
web2                       : ok=8    changed=0    unreachable=0    failed=0    skipped=1    rescued=0    ignored=0   

PLAY RECAP *********************************************************************
db1                        : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web1                       : ok=13   changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   
web2                       : ok=13   changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0   

Both playbooks clean again. site_name stays only in the role's defaults, since nothing outside the role uses it. This is why the health check exists, and why it's worth running after every change: site.yml alone said the refactor was perfect.

Overriding a default

Defaults are the lowest priority there is. Anything else wins: group_vars, host_vars, the inventory, and -e. A dry run on web1 with a different site name:

BASH
ansible-playbook site.yml --limit web1 --check --diff -e site_name=books
OUTPUT
TASK [nginx : Write the bookshop site's config] ********************************
--- before: /etc/nginx/sites-available/bookshop
+++ after: /Users/you/.ansible/tmp/ansible-local-83783umh9szi2/tmpja_2qtol/bookshop.conf.j2
@@ -3,7 +3,7 @@
 server {
     listen 80 default_server;
     listen [::]:80 default_server;
-    server_name bookshop;
+    server_name books;
 
     root /var/www/html;
     index index.html;
@@ -12,5 +12,5 @@
         try_files $uri $uri/ =404;
     }
 
-    access_log /var/log/nginx/bookshop.access.log;
+    access_log /var/log/nginx/books.access.log;
 }

changed: [web1]

The -e beat the role's default, and the template would have used it. This is what makes a role reusable: the role works with no settings at all, and each place that uses it changes only what it needs to.

In real systems

  • Most roles aren't written from scratch. Ansible Galaxy (opens in a new tab) shares thousands of them, for databases, web servers, monitoring agents and more. A requirements.yml lists the roles a project needs, with versions, and ansible-galaxy install -r requirements.yml fetches them. Read one before you run it: a role runs with whatever access your playbook has.
  • Name role variables after the role. nginx_http_port, not http_port, so two roles can't collide over one name. The bookshop kept the old names, so as not to change the inventory and health.yml in the same lesson. Lesson 10's linter will point it out.
  • Roles have their own repositories, eventually. A team with many projects keeps shared roles in one place, each with its own tests and version tags, and every project pins the version it uses. Same idea as a pinned image tag in a GitOps repository: change on purpose, not by accident.
  • A refactor gets its own change. Moving code and changing behavior in one go makes it impossible to tell which caused a problem. Split, prove changed=0, commit; then change things.

Spot the problem

Work out what's wrong before you read the explanation.

The port that wouldn't change. Someone writes a role for an internal app, and puts app_port: 3000 in roles/app/vars/main.yml. One server needs port 3001, so they set app_port: 3001 in its host_vars file. The playbook runs cleanly, but the server still listens on 3000. They try group_vars, then the inventory line: still 3000. Only -e app_port=3001 works. Why? vars/main.yml is the wrong folder. A role's vars have a higher priority than anything in the inventory, so they beat host_vars and group_vars; only a few things, -e among them, are higher. vars is for values the role really shouldn't let anyone change, like the name of a package on a particular Linux version. A setting people are meant to change goes in defaults/main.yml, where everything else wins.

Keep this

ToWrite or run
Make an empty roleansible-galaxy role init --init-path roles myrole
A role's tasksroles/myrole/tasks/main.yml
Its handlersroles/myrole/handlers/main.yml
Its templates and filesroles/myrole/templates/, roles/myrole/files/, used without a path
Settings anyone can overrideroles/myrole/defaults/main.yml
Settings the inventory can't overrideroles/myrole/vars/main.yml, rarely
Use roles in a playroles:, then - myrole
List tasks, with their rolesansible-playbook site.yml --list-tasks
See what the inventory gives a serveransible-inventory --host web1
Prove a refactor changed nothing--check --diff, then a real run: changed=0
Install shared rolesansible-galaxy install -r requirements.yml

Where you are now

The bookshop's servers are described by two roles: a baseline for every server, and nginx for the web servers. site.yml just says which role goes where. You've proved the split changed nothing, and found the one thing it broke, because you ran the health check too. Every role so far has used settings anyone can read. Lesson 8 adds one that nobody should: a database password, kept in the repository encrypted, with Ansible Vault.

Check yourself

  1. What's the only part a role must have?

  2. After the split, site.yml and the real run showed changed=0. Why is that the result you want?

  3. Why did health.yml fail on web1 but not web2?

  4. A value is needed by site.yml and health.yml. Where should it live?

  5. Which wins: a role's defaults/main.yml, or the same variable in group_vars?

  6. A setting in roles/app/vars/main.yml won't change from host_vars. What's the fix?

Key terms

  • Role: a folder holding everything for one job: tasks, handlers, templates, files and default settings, in known places.
  • roles: the part of a play that lists the roles to run.
  • Role defaults: a role's settings in defaults/main.yml, at the lowest priority of any variable.
  • Role vars: a role's settings in vars/main.yml, at a priority above the inventory's.
  • Refactor: changing how something is written without changing what it does.
  • ansible-galaxy: the command that makes empty roles and installs shared ones.
  • Ansible Galaxy: the public site where people share roles and collections.
  • requirements.yml: a list of the roles a project needs, with versions, for ansible-galaxy install.

Go deeper

  • The Ansible documentation: Roles (opens in a new tab), and Understanding variable precedence (opens in a new tab).
  • Try: give db1 a role of its own, roles/postgres, with one task that prints db_port from host_vars/db1.yml, and add a third play to site.yml. Run --list-tasks to see it.
  • Try: put http_port: 9090 in roles/nginx/vars/main.yml, and run site.yml --check --diff. Which servers would change, and why does web2's 8080 lose? Delete the file afterwards.