Roles & reuse
Package automation into shareable units.
A meal kit turns up as one box. The sauce sits in the same compartment every time, the recipe card is on top, and you never hunt for the garlic. That sameness is the product. An Ansible role does the same job for automation: a directory with a fixed set of named subfolders, so a role written by a stranger keeps its tasks, its variables, its templates and its restart instructions in exactly the places yours does. You can run it without reading a manual first.
Without roles, you copy. The block of tasks that installs nginx and locks it down gets pasted into the web playbook, then into staging, then into a teammate's repository. Six months on there are five copies. The day a CVE (Common Vulnerabilities and Exposures, the public tracking number given to a known security bug) lands against that config, you patch four of them and forget the fifth. The fifth is the one that stays exposed. A role folds those copies into one named unit with one place to fix.
There is a sharper reason to care, and most introductions skip it. A role is code. Roles almost always run under become: true (Ansible's way of saying run this with sudo), which means root on every host in the play. Choosing to reuse a role and choosing to trust it are the same decision, and you make it the moment you type its name.
The Fixed Folder Layout
A hardware store keeps every size of screw in a labelled drawer, not in whatever box had room that morning. Roles work the same way: convention instead of configuration. Ansible checks a known set of subdirectories, and inside the ones that hold YAML (the indented text format playbooks are written in) it loads a file called main.yml on its own. You never write an include path. Build the skeleton with the tool rather than by hand, so you get every drawer, including the ones you leave empty today.
# scaffold a role named webserver inside a project-local roles/ directoryansible-galaxy role init --init-path=roles webserver
- Role webserver was created successfully
# LC_ALL=C so the sort order is identical on every machinefind roles/webserver | LC_ALL=C sort
roles/webserverroles/webserver/README.mdroles/webserver/defaultsroles/webserver/defaults/main.ymlroles/webserver/filesroles/webserver/handlersroles/webserver/handlers/main.ymlroles/webserver/metaroles/webserver/meta/main.ymlroles/webserver/tasksroles/webserver/tasks/main.ymlroles/webserver/templatesroles/webserver/testsroles/webserver/tests/inventoryroles/webserver/tests/test.ymlroles/webserver/varsroles/webserver/vars/main.yml
tasks/main.yml is the front door: it runs when something calls the role. defaults/main.yml holds values a caller is expected to override. vars/main.yml holds values the role treats as its own business. handlers/main.yml holds the restart-and-reload tasks that other tasks notify, which works like a bell you ring for the kitchen instead of walking in and cooking yourself. meta/main.yml carries the role's metadata and its dependencies on other roles. files/ and templates/ get special treatment: ansible.builtin.copy and ansible.builtin.template look in the calling role's own files/ and templates/ first, so inside a role you write a bare filename and no path at all. The tests/ pair is a starter smoke-test playbook, meaning a tiny run that proves the basics work, and you can keep it or delete it.
---- name: Install nginxansible.builtin.package:name: "{{ webserver_package }}"state: present- name: Render the nginx configansible.builtin.template:src: nginx.conf.j2 # found in this role's templates/, no path neededdest: "{{ webserver_config_path }}"owner: rootgroup: rootmode: "0644"validate: nginx -t -c %s # check the rendered file before installing itnotify: Reload nginx
---- name: Reload nginxansible.builtin.service:name: "{{ webserver_service }}"state: reloaded
Two lines in that task file are doing quiet work. The src carries no directory because the template module searches this role's templates/ folder first, so moving the role into another project does not break the reference. And validate: nginx -t -c %s tells Ansible to write the rendered file to a temporary path on the managed host, run nginx's own syntax checker against it, and refuse to install it if the check fails. Drop that line and one bad variable ships a broken config to every web server, where the next reload takes the site down. The notify fires only when the template task actually reports changed, so a run that alters nothing reloads nothing. That is idempotence, meaning a second run changes nothing, doing its job.
Two Variable Folders That Behave Nothing Alike
A washing machine has dials on the front and screws inside the casing. The dials are for you. The screws are for the person who built it. A role splits its variables the same way, and the split is the whole point of having two folders.
---# Anything a caller might want to change lives here and nowhere else.webserver_port: 443webserver_server_name: localhost# webserver_allowed_cidrs is deliberately absent. It is a required option# in meta/argument_specs.yml, and a default here would satisfy that# requirement and switch the check off for good.
---# Constants the role owns. Callers have no business changing these.webserver_package: nginxwebserver_service: nginxwebserver_config_path: /etc/nginx/nginx.conf
Those two files look alike and behave nothing alike. Ansible keeps a long variable precedence list, and they sit at opposite ends of it. defaults/main.yml is the floor: your inventory (the list of hosts Ansible manages), group_vars (a file of variables applied to every host in a named group), host_vars, play vars and -e on the command line all beat it, which is exactly what you want for anything a caller might tune. vars/main.yml sits far higher, above group_vars and host_vars and above play vars. So a user who sets webserver_package in their inventory and sees nothing change is hitting that rule, with no error message to search for. Extra vars passed with -e sit at the very top and beat everything, including vars/, which makes -e a reliable emergency override and a terrible everyday habit.
Calling A Role From A Play
---galaxy_info:author: platform-teamdescription: Serve one site over TLS, restricted to named networkslicense: MITmin_ansible_version: "2.16" # quote it: unquoted 2.10 parses as the number 2.1galaxy_tags:- web- nginx# Pulled in and run before this role's own tasks.dependencies:- role: tls_certvars:tls_cert_domain: "{{ webserver_server_name }}"
---- name: Configure the public web tierhosts: webbecome: truepre_tasks:- name: Refresh the package indexansible.builtin.apt:update_cache: truecache_valid_time: 3600roles:- role: baseline # no per-call values- role: webservervars: # call-site values, above the role's defaults/webserver_port: 8443webserver_server_name: shop.example.comwebserver_allowed_cidrs:- 10.20.0.0/16- 10.30.4.0/24tasks:# dynamic: which roles run is not known until the play is running- name: Apply one app role per enabled appansible.builtin.include_role:name: "app_{{ item }}"loop: "{{ enabled_apps | default([]) }}"
That description line mentions TLS (Transport Layer Security, what puts the lock icon on an https:// address), and the tls_cert dependency is the role that gets the certificate in place. The roles: keyword is the everyday way in. Every role listed there runs before the play's own tasks: block, which catches people who expect strict top-to-bottom reading order. pre_tasks runs earlier still, and post_tasks runs last. Each role entry can carry its own vars:, so one role can be called twice with a different port and a different server name, and those call-site values beat anything in the role's defaults/. Dependencies declared in meta/main.yml are pulled in ahead of the role that needs them, which is why tls_cert runs before webserver without the play mentioning it at all.
The keywords import_role and include_role give you finer control. import_role is static: Ansible reads it while parsing the playbook, so its tasks are known before the run starts, and a tag (a label you attach to tasks so you can run or skip a subset) placed on the import applies to every task inside. include_role is dynamic, resolved during the run, and it is the only way to loop over role names or pick one with a when. The trade is visibility. A dynamic include is opaque until it executes, and a tag on it covers the include itself and nothing within, unless you add apply:. One rule catches everybody eventually: a role that already ran in a play will not run again, even if you list it twice, unless the parameters differ or its meta/main.yml sets allow_duplicates: true. The second listing produces no output and no skipped line in the recap, so it reads like Ansible ignored your file.
Check The Wiring Before You Run It
# print the compiled task list without touching a single hostansible-playbook -i inventory site.yml --list-tasks
playbook: site.ymlplay #1 (web): Configure the public web tier TAGS: []tasks:Refresh the package index TAGS: []baseline : Install the base package set TAGS: []baseline : Harden sshd TAGS: []tls_cert : Ensure the certificate exists TAGS: []webserver : Validating arguments against arg spec 'main' - Serve one site over TLS, restricted to named networks TAGS: [always]webserver : Install nginx TAGS: []webserver : Render the nginx config TAGS: []Apply one app role per enabled app TAGS: []
Read that as the compiled plan. Role tasks appear with the role name in front, which is how you confirm Ansible found the role you meant and not a same-named copy elsewhere on the search path. It looks in a roles/ directory next to the playbook first, then in roles_path, which defaults to ~/.ansible/roles, /usr/share/ansible/roles and /etc/ansible/roles. The odd line about an arg spec is a task Ansible inserted for you, and the next section builds it. Now notice what is missing. The include_role task shows up once, under its own name, with nothing beneath it, because dynamic includes are not expanded at parse time. Whatever hides behind one stays invisible until the run. This listing is a parse-time plan, not a prediction of what executes, so a role you listed twice still shows two sets of tasks here even though the second set gets passed over at run time. To see effects rather than plans, follow up with --check --diff against one host using --limit, and run ansible-lint roles/webserver to check the role on its own with no playbook involved.
Roles do not only live in a roles/ folder. Collections ship them too. A collection is a bundle of modules, roles and plugins packaged and versioned as one unit, and you address a role inside one by its fully qualified collection name (FQCN: namespace, then collection, then role), as in fedora.linux_system_roles.timesync. That is a full postal address rather than a street name, so it skips the search path entirely and there is no argument about which copy ran. Mixing both styles is normal and healthy: the roles you wrote sit in roles/ next to the playbook and travel with the repository, while anything you did not write arrives through a pinned collection or a pinned Galaxy install.
Make The Role Refuse Bad Input
A role's defaults tell a caller what they may set. Nothing so far tells them what they must set, and that gap is where security bugs live. A form at a doctor's office will not be accepted with the allergies box blank, and that refusal is the whole feature. meta/argument_specs.yml gives your role the same power: a typed contract for its inputs that Ansible checks by itself, before the role's first real task. Mark webserver_allowed_cidrs (CIDR is Classless Inter-Domain Routing, the 10.20.0.0/16 way of writing a block of addresses) as required with no default anywhere, and a caller who forgets the allowlist never gets past the front door. Each entry point gets its own key, so a role that offers an alternative way in through tasks_from: install.yml validates that path separately under an install: key.
---argument_specs:main: # the spec for tasks/main.ymlshort_description: Serve one site over TLS, restricted to named networksoptions:webserver_server_name:type: strrequired: truedescription: Host name the site answers towebserver_port:type: intdescription: TCP port the site listens onwebserver_allowed_cidrs:type: listelements: strrequired: truedescription: Networks allowed to reach the port
# someone copied the play for staging and dropped the allowlist linesansible-playbook -i inventory site.yml --limit web01
PLAY [Configure the public web tier] *******************************************TASK [Gathering Facts] *********************************************************ok: [web01]TASK [Refresh the package index] ***********************************************ok: [web01]TASK [baseline : Install the base package set] *********************************ok: [web01]TASK [baseline : Harden sshd] **************************************************ok: [web01]TASK [tls_cert : Ensure the certificate exists] ********************************ok: [web01]TASK [webserver : Validating arguments against arg spec 'main' - Serve one site over TLS, restricted to named networks] ***fatal: [web01]: FAILED! => {"argument_errors": ["missing required arguments: webserver_allowed_cidrs"], "argument_spec_data": {"webserver_allowed_cidrs": {"description": "Networks allowed to reach the port", "elements": "str", "required": true, "type": "list"}, "webserver_port": {"description": "TCP port the site listens on", "type": "int"}, "webserver_server_name": {"description": "Host name the site answers to", "required": true, "type": "str"}}, "changed": false, "msg": "Validation of arguments failed:\nmissing required arguments: webserver_allowed_cidrs", "validate_args_context": {"argument_spec_name": "main", "name": "webserver", "path": "/home/ops/infra/roles/webserver", "type": "role"}}PLAY RECAP *********************************************************************web01 : ok=5 changed=0 unreachable=0 failed=1 skipped=0 rescued=0 ignored=0
That failure costs about a second, and it lands before a single package is installed or a config file is written. Compare it against the alternative. A role author who writes the allowlist as a Jinja expression (Jinja is the template language behind Ansible's {{ }} placeholders) with a fallback of 0.0.0.0/0 ships a listener open to the whole internet, quietly, on every host, and the run still reports green. The check has one requirement that is easy to get wrong: a required option must have no value in defaults/main.yml, because a default makes the variable present and the requirement can then never fire. The validation task carries the always tag, so narrowing a run with --tags still performs it. Only an explicit --skip-tags always gets around it, which is exactly as visible as it should be. The same file doubles as documentation you can print with ansible-doc -t role -r roles webserver.
Installing Someone Else's Role
Ansible Galaxy is the public index where people publish roles and collections, and installing from it takes one command. What you want alongside that is a receipt: a file inside your repository saying exactly which version of which role this project runs, so the next person and the build server get the same bytes you did. requirements.yml is that receipt.
---roles:# Galaxy role pinned to a tag. Tags can be moved, so this is the weaker pin.- name: geerlingguy.nginxversion: "3.1.4"# Git role pinned to a commit SHA. Exact bytes, and it cannot be re-pointed.- name: appsrc: https://github.com/acme/ansible-role-app.gitscm: gitversion: "0f5a1c8e3b2d4f6a8c9e1b3d5f7a9c1e3b5d7f9a"collections:- name: community.generalversion: "8.6.0"# one file, both lists: ansible-galaxy install -r requirements.yml
ansible-galaxy install -r requirements.yml
Starting galaxy role install process- downloading role 'nginx', owned by geerlingguy- downloading role from https://github.com/geerlingguy/ansible-role-nginx/archive/3.1.4.tar.gz- extracting geerlingguy.nginx to /home/ops/.ansible/roles/geerlingguy.nginx- geerlingguy.nginx (3.1.4) was installed successfully- extracting app to /home/ops/.ansible/roles/app- app (0f5a1c8e3b2d4f6a8c9e1b3d5f7a9c1e3b5d7f9a) was installed successfullyStarting galaxy collection install processProcess install dependency mapStarting collection install processDownloading https://galaxy.ansible.com/api/v3/plugin/ansible/content/published/collections/artifacts/community-general-8.6.0.tar.gz to /home/ops/.ansible/tmp/ansible-local-8213hs4kq1zr/tmp0v9wbnq2/community-general-8.6.0-i4t2r8xaInstalling 'community.general:8.6.0' to '/home/ops/.ansible/collections/ansible_collections/community/general'community.general:8.6.0 was installed successfully
Pinning is the difference between a build that reproduces and a build that drifts. Both role entries above are pinned, but not equally well. A Galaxy version like 3.1.4 points at a Git tag, and a tag is a sticker: someone can peel it off and stick it on a different jar without changing what the label says. A 40-character commit SHA (Secure Hash Algorithm, the hexadecimal fingerprint Git gives every commit) names exact bytes and cannot be re-pointed. Prefer a SHA for anything you did not write. Roles land in the first writable directory of roles_path and collections under ~/.ansible/collections, so third-party code never ends up committed inside your own repository, and a single requirements.yml carries both kinds.
ansible-galaxy role list
# /home/ops/.ansible/roles- app, 0f5a1c8e3b2d4f6a8c9e1b3d5f7a9c1e3b5d7f9a- geerlingguy.nginx, 3.1.4[WARNING]: - the configured path /usr/share/ansible/roles does not exist.[WARNING]: - the configured path /etc/ansible/roles does not exist.
That listing answers the question of what is on this machine, which is not always the same as what requirements.yml says. A control node (the machine you run ansible-playbook from) that has been in service a while collects roles installed by hand, by an older requirements file, or by a colleague. ansible-galaxy install leaves an already-present role alone and prints a line like "- geerlingguy.nginx (3.1.4) is already installed, skipping." unless you pass --force, so a stale copy can outlive the pin that was supposed to replace it. When the two lists disagree, the run you are about to make is not the run your repository describes. The cheap fix is a fresh control node, or a throwaway roles directory per build, so nothing survives from one run to the next.
# before you trust a role you did not write, find out what it actually doesgrep -rnE '^[[:space:]]*-?[[:space:]]*(ansible\.builtin\.)?(shell|command|raw|script|get_url|unarchive):' ~/.ansible/roles/app/
/home/ops/.ansible/roles/app/tasks/install.yml:14: ansible.builtin.get_url:/home/ops/.ansible/roles/app/tasks/install.yml:22: ansible.builtin.unarchive:/home/ops/.ansible/roles/app/tasks/install.yml:31: ansible.builtin.shell: |/home/ops/.ansible/roles/app/tasks/main.yml:9: command: /opt/app/bin/app --migrate
Read the hits rather than counting them. The pattern allows both spellings on purpose, because plenty of published roles still use the old short names, and searching for ansible.builtin.shell alone would have missed that last line entirely. get_url and unarchive mean the role fetches something off the internet while it runs, so the pin in requirements.yml covers the role and not the payload it downloads. A shell or command task means arbitrary commands as root. None of that is automatically wrong, since a role that installs software has to do some of it. What matters is that you looked, that the URL belongs to a host you would trust with root on your fleet, and that you can say out loud what this role does without shrugging.
Try this
Run ansible-galaxy role init --init-path=roles webserver on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: defaults/ and vars/ are not interchangeable. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.