Understand why Ansible Roles became the industry standard for packaging automation. Learn the standard role directory structure, creating roles with ansible-galaxy role init, and a case study refactoring a LEMP stack playbook into modular roles.

After episode 11, where we covered modularizing playbooks using include_tasks, import_tasks, and import_playbook, in this episode we'll cover the natural evolution of that concept: Ansible Roles.
In episode 11, we broke large playbooks into small task files inside a tasks/ directory. That was a good step, but imagine having to share that tasks/webserver.yml with another team or project. You'd have to explain "don't forget to also include its handler file, its templates, and its default variables". Without a standard rule, everyone would package the same thing differently, and ultimately automation becomes hard to reuse across projects.
In the real working world, this principle is known as standardization: if everyone follows the same folder structure, then anyone can pick up someone else's role and use it directly without reading long documentation. This is why Roles became the industry standard. Similar to how npm manages JavaScript packages or pip manages Python packages, Roles are Ansible's way of packaging automation into complete, self-contained, ready-to-share units.
In this episode, we'll cover what a role is, why roles matter, the anatomy of its standard directory structure, how to create a role from the CLI using ansible-galaxy role init, a case study transforming a monolithic LEMP stack playbook into modular roles, and the inter-role dependency feature in meta/main.yml.
Before understanding roles, let's reflect on the problem they aim to solve. In episode 11, we split playbooks into several task files. The structure looked roughly like this:
ansible-project/
├── tasks/
│ ├── common.yml
│ ├── webserver.yml
│ └── database.yml
├── handlers/
│ └── main.yml
├── templates/
│ └── nginx.conf.j2
└── playbook.ymlThis structure is already much tidier, but it has a fundamental weakness: there's no binding convention. What's the name of the task file for webserver installation? Should handlers go in a handlers/ folder? What about the default variables and templates that accompany it? Everything is left to each individual, so when one project is shared with another, people have to guess.
Roles solve this problem with one simple idea: location convention. A role is a directory structure whose name and contents are predefined by Ansible. When you see a directory named roles/nginx, you immediately know that:
tasks/main.yml contains the role's main task list.handlers/main.yml contains the handlers the role needs.defaults/main.yml contains default variables that can be overridden.templates/ contains Jinja2 templates.files/ contains static files.meta/main.yml contains role metadata and dependencies.Think of it like a recipe box. A plain recipe is written on a scrap of paper that's easy to lose, and everyone writes its format differently. A role is a box containing a complete recipe, ingredient list, required tools, and cooking steps, all with standard labels. Anyone who receives the box immediately knows how to use it.
Roles' main advantages in production:
Now let's dissect the role directory structure in detail. Here's an example tree diagram for the nginx role:
roles/
└── nginx/
├── tasks/
│ ├── main.yml # Entry point: daftar task utama (wajib ada)
│ └── ...
├── handlers/
│ └── main.yml # Handler yang bisa di-notify oleh task role
├── defaults/
│ └── main.yml # Variabel default paling rendah prioritasnya
├── vars/
│ └── main.yml # Variabel internal role, prioritas lebih tinggi
├── templates/
│ └── nginx.conf.j2 # Template Jinja2 untuk konfigurasi dinamis
├── files/
│ └── index.html # File statis untuk di-copy ke target
├── meta/
│ └── main.yml # Metadata & role dependencies
├── library/ # (opsional) Custom module khusus role
├── module_utils/ # (opsional) Helper untuk custom module
├── plugins/ # (opsional) Plugin khusus role
└── README.md # (opsional) Dokumentasi roleNote
Only tasks/main.yml is genuinely required in a role. The other directories are optional and only need to be created if actually used. However, for a tidy, production-grade role, all relevant directories are usually still provided, even if their contents are thin.
Each directory has a clear role. Here's the summary:
| Directory / File | Function | Variable Precedence |
|---|---|---|
tasks/main.yml | Main task list executed when the role is used | — |
handlers/main.yml | Handlers that can be triggered by notify from role tasks | — |
defaults/main.yml | Default variables with the lowest precedence (easy to override) | Lowest |
vars/main.yml | Role internal variables, higher precedence than defaults | Medium |
templates/ | Jinja2 templates rendered with the template module | — |
files/ | Static files copied with the copy module | — |
meta/main.yml | Metadata (galaxy_info, author) & role dependencies | — |
An important point that's often misunderstood is the difference between defaults/ and vars/. Both hold variables, but their purposes differ:
defaults/main.yml — holds default values intentionally made easy to override by the role user. Variables here have the lowest precedence in Ansible's entire precedence hierarchy, so role users can replace them from group_vars, host_vars, --extra-vars, and so on.vars/main.yml — holds internal values that role users ideally shouldn't touch, like specific tested package versions or internal paths. Its precedence is higher than group_vars, so changing it via group_vars won't have any effect.ansible-galaxy role initThe best way to understand the role structure is to create one directly using the CLI. Ansible provides the ansible-galaxy role init command, which automatically generates a complete role skeleton:
ansible-galaxy role init roles/nginxThe output that will appear in the terminal:
- Role nginx was created successfullyAs soon as the command finishes, Ansible immediately creates the following directory structure:
roles/nginx/
├── README.md
├── defaults
│ └── main.yml
├── files
├── handlers
│ └── main.yml
├── meta
│ └── main.yml
├── tasks
│ └── main.yml
├── templates
├── tests
│ ├── inventory
│ └── test.yml
└── vars
└── main.ymlNotice that ansible-galaxy role init also creates a tests/ directory containing an inventory and a simple test playbook. This directory will be useful later when you learn role testing with Molecule in episode 18.
Tip
There are a few additional useful options for ansible-galaxy role init. Use --init-path <path> to set the creation location, --force to overwrite an existing directory, and --role-skeleton <path> to use your team's own custom skeleton. Custom skeletons are very common in companies to include a team-specific README template and license.
Theory matters, but we need to see how roles solve real problems. Let's take a classic case study: LEMP Stack setup (Linux, Nginx, MySQL/MariaDB, PHP). Here's a comparison of the monolith playbook before and after refactoring into roles:
The monolith playbook above works, but it has all the problems we've already discussed: hard to read, hard to test per-component, and not reusable. If tomorrow the team needs a LEMP setup in another project, the entire block has to be copy-pasted. In contrast, the refactored playbook becomes very concise and intent-revealing: from reading it alone, you immediately understand which components are managed.
Now let's refactor that playbook into a roles structure. First, initialize the three roles:
ansible-galaxy role init roles/nginx
ansible-galaxy role init roles/mariadb
ansible-galaxy role init roles/phpAfter the refactor, the project directory structure becomes:
ansible-lemp/
├── ansible.cfg
├── inventory.yml
├── site.yml
├── group_vars/
│ └── all.yml
└── roles/
├── nginx/
│ ├── tasks/main.yml
│ ├── handlers/main.yml
│ ├── defaults/main.yml
│ ├── templates/nginx.conf.j2
│ └── meta/main.yml
├── mariadb/
│ ├── tasks/main.yml
│ ├── handlers/main.yml
│ ├── defaults/main.yml
│ └── meta/main.yml
└── php/
├── tasks/main.yml
├── handlers/main.yml
├── defaults/main.yml
└── meta/main.ymlNotice the difference. The main playbook that used to be dozens of lines is now only three lines. All implementation details move into each role, where every role can be tested, maintained, and reused independently. This is the essence of true modularization.
nginx RoleNow let's fill the nginx role with a complete implementation. This will show how task, handler, defaults, and template work together in one package.
First, the role's default variables in roles/nginx/defaults/main.yml:
---
nginx_port: 8080
nginx_user: www-data
nginx_server_name: localhostThen the main tasks in roles/nginx/tasks/main.yml:
---
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Copy konfigurasi Nginx
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: "0644"
notify: Restart NginxNotice two important details:
template module, src is written simply as nginx.conf.j2 without the roles/nginx/templates/ prefix. Ansible automatically looks for template files inside the templates/ directory of the currently active role. The same applies to the copy module, which looks for files in files/.notify: Restart Nginx keyword refers to the handler defined in handlers/main.yml:---
- name: Restart Nginx
ansible.builtin.service:
name: nginx
state: restarted
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloadedFinally, the configuration template in roles/nginx/templates/nginx.conf.j2:
server {
listen {{ nginx_port }};
server_name {{ nginx_server_name }};
root /var/www/html;
index index.php index.html;
location ~ \.php$ {
include snippets/fastcgi-php.conf;
fastcgi_pass unix:/run/php/php8.2-fpm.sock;
}
}Important
Inside a role, variables defined in defaults/main.yml (like nginx_port) are automatically available to all tasks, templates, and handlers within that role. No more defining vars: in the playbook — this is one of the role's big advantages: the user only sets the default variables they want to override, and the rest is managed by the role.
There are three ways to use a role in a playbook, and each has slightly different behavior.
First way: the roles: keyword. This is the most common and simplest. Roles run before the play's regular tasks (order: pre_tasks → roles → tasks → post_tasks):
---
- name: Deploy aplikasi web
hosts: webservers
become: true
roles:
- role: nginx
vars:
nginx_port: 8443
- role: mariadb
tasks:
- name: Deploy aplikasi custom
ansible.builtin.copy:
src: files/app.tar.gz
dest: /opt/app/app.tar.gzNotice that we can give per-role specific variables using the role: <name> syntax with vars: beneath it. This enables using the same role with different configurations in different plays.
Second way: the include_role keyword (dynamic). The role is loaded at runtime, similar to include_tasks. Useful when the role name depends on a variable, or when you want to loop:
---
- name: Terapkan role berdasarkan variabel
hosts: all
tasks:
- name: Include role app sesuai environment
ansible.builtin.include_role:
name: "{{ app_role }}"Third way: the import_role keyword (static). The role is processed at playbook parsing time, similar to import_tasks. Its advantage: the tasks inside the role are visible in --list-tasks:
---
- name: Setup web server
hosts: webservers
tasks:
- name: Import role nginx secara statis
ansible.builtin.import_role:
name: nginxWarning
The big difference between these three ways: with the roles: keyword and import_role (static), the role's contents are immediately visible when running ansible-playbook site.yml --list-tasks, so operations like --start-at-task and the --tags filter work reliably. With include_role (dynamic), the role's contents are only known at runtime. Use the roles: keyword as the default, and switch to include_role only when you genuinely need dynamic role name flexibility or looping.
meta/main.ymlIn the real world, roles often depend on each other. For example, the php role might not require Nginx, but the mariadb role definitely needs base packages installed. To handle this, Ansible provides role dependencies declaration in meta/main.yml:
---
galaxy_info:
author: arman
description: Role untuk setup MariaDB
license: MIT
min_ansible_version: "2.16"
dependencies:
- role: common
vars:
common_packages:
- curl
- gnupg
- role: apt-transport-https
when: ansible_facts['os_family'] == "Debian"When the mariadb role is used, Ansible automatically runs the common and apt-transport-https roles first (in list order). We can also pass variables to the dependency role and add a when condition to make sure the dependency only runs on specific OSes.
Tip
Beware of excessive dependencies. Every role dependency adds execution time and increases the chance of variable conflicts. The rule of thumb: only declare dependencies you genuinely need, and make sure those dependencies are also idempotent.
Because roles are reused logic blocks, understanding how variables work inside them is crucial. In episode 7 we already covered the variable precedence hierarchy. Inside a role, defaults has the lowest precedence and vars has a fairly high precedence. Here's a brief overview of the order (weakest to strongest):
| Variable Source | Precedence |
|---|---|
roles/xxx/defaults/main.yml | Weakest |
Inventory host/group vars (host_vars/group_vars) | Weak |
Playbook vars: | Medium |
roles/xxx/vars/main.yml | Strong |
include_role / import_role params vars: | Strong |
--extra-vars | Strongest |
The practical consequence: if you want role variables to be easily overridable by users (e.g., port, domain, package version), place them in defaults/. If you want those values "locked in" and hard to change, place them in vars/.
1. Assuming all role directories are required
Only tasks/main.yml is required. Creating empty templates/ and files/ is fine, but an empty meta/main.yml is also normal as long as there are no dependencies. Don't create unused directories just to follow the crowd.
2. Writing src with an absolute path in the template/copy module
Inside a role, src: nginx.conf.j2 is automatically looked up in roles/nginx/templates/. Writing src: roles/nginx/templates/nginx.conf.j2 isn't just redundant, it's also prone to breakage when the role is moved or installed from Galaxy.
3. Defining role variables in playbook vars: when they should be in defaults/
Variables defined in the playbook will override defaults, making the role no longer "portable". The best practice: default values in defaults/, environment-specific values in group_vars or --extra-vars.
4. Placing secret variables in vars/ then committing to Git
vars/ and defaults/ are both version-controllable. Never put passwords or API keys in them. We'll discuss the correct solution, Ansible Vault, in depth in episode 14.
5. Ignoring meta/main.yml when the role will be shared
If the role will be published to Ansible Galaxy, meta/main.yml holds the galaxy_info that displays author, license, and minimum Ansible version information. A role without clear metadata is hard to find and to be trusted by the community.
6. Nesting roles too deeply
A role calling another role that calls another role will be very hard to debug. If the dependency hierarchy is already three levels deep, consider merging or redesigning.
In episode 12, we learned that Ansible Roles are the industry standard for packaging automation because they bring a standard structure convention, tidy encapsulation, and high reusability. We dissected the anatomy of the role directory structure from tasks, handlers, defaults, vars, templates, files, to meta, understood the difference between defaults and vars, created a role skeleton with ansible-galaxy role init, transformed a monolithic LEMP stack playbook into three modular roles, and learned how to use roles through the roles: keyword, include_role, and import_role, along with role dependency declarations.
Key points to take home:
defaults/ for easily overridable variables, vars/ for locked-in internal values.ansible-galaxy role init quickly generates a complete skeleton.meta/main.yml.Now you have the foundation to organize automation professionally. However, when you start using other people's roles or need specialized cloud modules (e.g., AWS, Kubernetes), you'll encounter the term Collections — a more modern concept that's bigger than just roles.
In episode 13, we'll cover Introduction and Explanation of Ansible Collections & Galaxy — how the Ansible ecosystem evolved from classic ansible to ansible-core + ansible-community, what namespace and FQCN are, and how to find and install collections from Ansible Galaxy. Keep your enthusiasm up!