Red Hat Certified Engineer (RHCE) #11: Writing and Using Roles
If #10 Ansible Vault had you encrypting secrets to handle them safely, this time we cover the role that bundles a playbook into a reusable unit. A role groups tasks, handlers, variables, templates, and files into a fixed directory structure so that a configuration written once can be called from many playbooks. The “write a role and call it from a playbook” task type comes up on almost every RHCE exam, so in this post we’ll get the structure and usage into your hands.
Why you need roles #
The playbooks we’ve written so far have had their tasks, handlers, variables, and templates scattered across one file or a few files. For small configurations that’s fine, but if you want to reuse the same bundle of package installs, config deployments, and service startups in another playbook, you have to copy it every time. The more copied code piles up, the harder it is to maintain — fixing one spot means hunting down and fixing every copy, and inconsistencies creep in fast.
A role organizes this bundle once into an agreed-upon directory structure, and the playbook then calls it just by name. You gain the following advantages.
- Reuse. A role written once is called from many playbooks.
- Structure. Tasks, handlers, variables, and files go in fixed locations, so they’re easy to find.
- Sharing. You can pull them down with ansible-galaxy or package them in a collection to distribute.
The role directory structure #
A role follows fixed subdirectory names. The main.yml in each directory is the entry point that loads automatically. The standard structure is as follows.
roles/
└── webserver/
├── tasks/
│ └── main.yml # tasks the role performs
├── handlers/
│ └── main.yml # handlers called by notify
├── defaults/
│ └── main.yml # default variables (lowest precedence)
├── vars/
│ └── main.yml # role variables (higher precedence)
├── templates/
│ └── httpd.conf.j2 # the src base path for the template module
├── files/
│ └── index.html # the src base path for the copy module
├── meta/
│ └── main.yml # dependencies and metadata
└── README.mdThe key point here is automatic path lookup. If you write just src: httpd.conf.j2 for the template module inside a role’s task, Ansible automatically looks for it under that role’s templates/. The src of the copy module likewise looks under files/. So inside a role you don’t have to write the full path.
Scaffolding a role with ansible-galaxy #
You don’t need to create the empty directories by hand. The ansible-galaxy role init command generates the standard structure in one shot.
# run inside the roles directory
$ cd roles
$ ansible-galaxy role init webserver
- Role webserver was created successfully
$ tree webserver
webserver/
├── defaults
│ └── main.yml
├── files
├── handlers
│ └── main.yml
├── meta
│ └── main.yml
├── README.md
├── tasks
│ └── main.yml
├── templates
├── tests
│ ├── inventory
│ └── test.yml
└── vars
└── main.ymlEach generated main.yml is an empty skeleton, so you only fill in the directories you need. On the exam, scaffolding with this command and then writing just tasks/main.yml plus the variables and templates you need is the fast flow.
Writing the role contents #
Using the webserver role as an example, we’ll fill in tasks, handlers, defaults, and a template.
tasks/main.yml #
---
# roles/webserver/tasks/main.yml
- name: Install the httpd package
ansible.builtin.dnf:
name: httpd
state: present
- name: Deploy index.html
ansible.builtin.copy:
src: index.html # automatically finds files/index.html
dest: /var/www/html/index.html
- name: Deploy the httpd config
ansible.builtin.template:
src: httpd.conf.j2 # automatically finds templates/httpd.conf.j2
dest: /etc/httpd/conf/httpd.conf
notify: restart httpd
- name: Start the httpd service
ansible.builtin.service:
name: httpd
state: started
enabled: truehandlers/main.yml #
---
# roles/webserver/handlers/main.yml
- name: restart httpd
ansible.builtin.service:
name: httpd
state: restarteddefaults/main.yml #
---
# roles/webserver/defaults/main.yml
http_port: 80
server_admin: admin@example.comVariables placed in defaults have the lowest precedence, so when you call the role from a playbook and pass a value of the same name, you can override them easily. By convention, values that the role’s caller may want to adjust go in defaults, not vars.
Using a role #
There are three main ways to call a role you’ve written from a playbook: the roles key, include_role, and import_role — and they behave differently.
The roles key #
The simplest way is to list it under the play-level roles key. The role’s tasks run before the play’s regular tasks.
---
# site.yml
- name: Configure the web server
hosts: web
become: true
roles:
- webserverTo pass variables, you write it like this.
roles:
- role: webserver
vars:
http_port: 8080include_role and import_role #
If you call a role from within the task list instead of the roles key, you can directly control where it runs. Here the difference between the two modules is an exam point.
| Item | import_role | include_role |
|---|---|---|
| Processing time | Static. Included ahead of time during playbook parsing | Dynamic. Included at run time |
| when behavior | The condition applies to each internal task individually | The condition applies once to the role call |
| loop use | Not allowed | Allowed. Calls the role repeatedly |
| tag inheritance | Internal tasks inherit the tag | The tag applies only to the calling task |
---
- name: Call the role conditionally
hosts: web
become: true
tasks:
- name: Run a regular task first
ansible.builtin.debug:
msg: "before the role call"
- name: Call the webserver role dynamically
ansible.builtin.include_role:
name: webserver
when: enable_web | default(false)To sum up, use import_role when you can include it statically ahead of time, and use include_role when you need to decide at run time whether or how many times it runs via a condition or loop.
Role variable precedence #
A role can place variables in two locations, defaults and vars, and the two have different precedence. Within the full precedence covered in #6 Variables and facts, narrowing down to just the role-related positions gives the following.
defaults/main.yml. The lowest precedence. Almost every other variable overrides it.- The value passed via
vars:at role-call time. Overrides defaults. vars/main.yml. Higher than defaults.- Play-level and caller-side
vars, inventory variables, extra vars (-e). Override from higher positions.
The key is that defaults is the lowest. Put values that the role’s user should be able to change in defaults, and values that must stay fixed inside the role in vars. If you put the same variable in both places at once, vars wins, which makes it hard to override from outside — so take care.
Role dependencies #
If one role needs another role to run first, declare it in dependencies in meta/main.yml. A declared dependent role runs before the current role.
---
# roles/webserver/meta/main.yml
dependencies:
- role: common # runs before webserver
- role: firewall
vars:
firewall_ports:
- 80/tcpSince the common and firewall roles run ahead of webserver, you can bundle prerequisite work like installing common packages or opening the firewall as dependencies. You can also pass values to dependencies via vars, as above.
roles_path: where roles are found #
Ansible looks for roles in the following order.
- The
roles/directory in the same location as the playbook - The paths specified in
roles_pathinansible.cfg
So if you place a roles/ directory next to the playbook, the role is recognized with no extra configuration. To put roles elsewhere, write the path in ansible.cfg.
# ansible.cfg
[defaults]
roles_path = ./roles:/etc/ansible/rolesOn the exam, creating roles under the working directory’s roles/ is usually the safest. Roles pulled down with ansible-galaxy also follow this path rule.
Full example: writing a role then calling it #
Let’s tie the pieces we’ve built so far into one flow. The working directory structure is as follows.
project/
├── ansible.cfg
├── inventory
├── site.yml
└── roles/
└── webserver/
├── tasks/main.yml
├── handlers/main.yml
├── defaults/main.yml
├── templates/httpd.conf.j2
└── files/index.htmlThe calling playbook is short, like this. Since the configuration is gathered inside the role, the playbook body just calls the role.
---
# site.yml
- name: Configure the web server group
hosts: web
become: true
roles:
- role: webserver
vars:
http_port: 8080
server_admin: web@example.comFor the run and the idempotency check, as usual you run it twice and see whether changed becomes 0 on the second run.
$ ansible-navigator run site.yml -m stdout
$ ansible-navigator run site.yml -m stdout # second run: changed=0 means idempotentExam points #
- Generate the standard directory structure first with
ansible-galaxy role init <name>. - The role directories are
tasks,handlers,defaults,vars,templates,files, andmeta, and eachmain.ymlis the automatic entry point. - Inside a role, the
srcoftemplateautomatically findstemplates/and thesrcofcopyfindsfiles/, so you don’t write the full path. - The
roleskey runs before regular tasks. If you need dynamic calls via condition or loop, useinclude_role; for static inclusion, useimport_role. - In role variable precedence,
defaultsis the lowest. Put values to be overridden from outside in defaults, and fixed values in vars. - Declare ordering between roles via
dependenciesinmeta/main.yml. - Roles are found in
roles/next to the playbook and inroles_pathofansible.cfg. The working directory’sroles/is the safest.
Wrap-up #
What this post locked in:
- A role is a unit that bundles tasks, handlers, variables, templates, and files into a standard directory for reuse.
- Scaffold a skeleton with
ansible-galaxy role initand fill in only the directories you need. - Calling is done via the
roleskey,import_role(static), andinclude_role(dynamic), and they behave differently. defaultshas the lowest precedence, so put default values there that are easy to override from outside.- Handle dependencies via
dependenciesinmeta/main.ymland the lookup path viaroles_path.
Next — Collections #
Once you’ve modularized your configuration with roles, the next step is the collection — the unit that bundles multiple roles plus modules and plugins for distribution.
In #12 Collections: Galaxy, Automation Hub, we’ll cover the structure of a collection, ansible-galaxy collection install, installing dependent collections with requirements.yml, the difference between Ansible Galaxy and Automation Hub, and the habit of referencing modules precisely with FQCNs (the ansible.builtin.dnf form).