Write an Ansible Role
Learn to build a reusable Ansible role from scratch: directory layout, defaults vs vars, handlers, templates, dependencies, and publishing to Ansible Galaxy.
Before you start
- ▸Ansible 2.12 or later installed on the control node
- ▸SSH access to at least one target host with a configured inventory
- ▸Basic familiarity with YAML syntax and Ansible playbook structure
Ansible roles let you package tasks, variables, handlers, and files into a reusable unit you can drop into any playbook or share on Ansible Galaxy. Once you understand the directory conventions, writing a role feels natural—and your playbooks shrink to clean, readable declarations. This guide walks through building a real role from scratch, covering every subdirectory that matters and the decisions behind them.
Prerequisites and Setup
You need Ansible 2.12 or later installed on your control node. Verify with:
ansible --version
Install it if needed:
# Debian/Ubuntu
sudo apt install ansible
# Fedora / RHEL family
sudo dnf install ansible
# Arch
sudo pacman -S ansible
Generate the Role Skeleton
Never create the directory tree by hand. ansible-galaxy role init produces the correct layout every time:
ansible-galaxy role init my_nginx
This creates:
my_nginx/
├── defaults/
│ └── main.yml
├── files/
├── handlers/
│ └── main.yml
├── meta/
│ └── main.yml
├── tasks/
│ └── main.yml
├── templates/
├── tests/
│ ├── inventory
│ └── test.yml
└── vars/
└── main.yml
Each directory has a specific contract. Ansible loads them automatically when you reference the role—no explicit includes needed for the standard files.
Understanding defaults vs vars
This is the most common point of confusion for new role authors.
- defaults/main.yml — lowest-priority variables. Callers can override these from a playbook, inventory, or the command line. Put user-facing settings here.
- vars/main.yml — higher-priority variables. These override almost everything except
extra_vars(-e on the CLI). Use them for internal role constants that callers should not need to change.
A practical rule: if you want someone using your role to be able to change it, put it in defaults. If it is an internal implementation detail, put it in vars.
cat my_nginx/defaults/main.yml
# defaults/main.yml
nginx_port: 80
nginx_worker_processes: auto
nginx_server_name: "_"
nginx_document_root: /var/www/html
cat my_nginx/vars/main.yml
# vars/main.yml
_nginx_service_name: nginx
_nginx_conf_dir: /etc/nginx/conf.d
Writing Tasks
Tasks live in tasks/main.yml. For anything beyond trivial complexity, split into multiple files and import them:
cat my_nginx/tasks/main.yml
# tasks/main.yml
---
- name: Include OS-specific variables
ansible.builtin.include_vars: "{{ ansible_os_family }}.yml"
- import_tasks: install.yml
- import_tasks: configure.yml
- import_tasks: service.yml
cat my_nginx/tasks/install.yml
# tasks/install.yml
---
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
become: true
cat my_nginx/tasks/configure.yml
# tasks/configure.yml
---
- name: Deploy nginx virtual host config
ansible.builtin.template:
src: vhost.conf.j2
dest: "{{ _nginx_conf_dir }}/default.conf"
owner: root
group: root
mode: '0644'
become: true
notify: Reload nginx
cat my_nginx/tasks/service.yml
# tasks/service.yml
---
- name: Ensure nginx is enabled and running
ansible.builtin.systemd:
name: "{{ _nginx_service_name }}"
state: started
enabled: true
become: true
Handlers
Handlers run once at the end of a play, only if notified. They are ideal for service restarts and reloads—the classic mistake is putting a service restart directly in tasks, which fires every run regardless of whether anything changed.
cat my_nginx/handlers/main.yml
# handlers/main.yml
---
- name: Reload nginx
ansible.builtin.systemd:
name: "{{ _nginx_service_name }}"
state: reloaded
become: true
- name: Restart nginx
ansible.builtin.systemd:
name: "{{ _nginx_service_name }}"
state: restarted
become: true
The notify key in a task must match the handler name exactly, including case.
Templates and Files
Static files go in files/ and are referenced with the ansible.builtin.copy module. Jinja2 templates go in templates/ and are referenced with ansible.builtin.template—Ansible resolves the path automatically; you never need to write the full path.
cat my_nginx/templates/vhost.conf.j2
server {
listen {{ nginx_port }};
server_name {{ nginx_server_name }};
root {{ nginx_document_root }};
worker_processes {{ nginx_worker_processes }};
location / {
index index.html index.htm;
}
}
Role Dependencies
If your role requires another role to have run first, declare it in meta/main.yml rather than calling it from tasks:
cat my_nginx/meta/main.yml
# meta/main.yml
---
galaxy_info:
role_name: my_nginx
author: yourname
description: Installs and configures nginx
license: MIT
min_ansible_version: "2.12"
platforms:
- name: Ubuntu
versions:
- jammy
- noble
- name: Fedora
versions:
- "39"
- "40"
dependencies:
- role: geerlingguy.firewall
vars:
firewall_allowed_tcp_ports:
- "{{ nginx_port }}"
Dependencies declared here are resolved automatically before your role's tasks run. If the same dependency appears in multiple roles within a play, Ansible runs it only once by default—set allow_duplicates: true in a role's meta if you genuinely need it to run multiple times.
Using the Role in a Playbook
Place your role directory inside a roles/ directory next to your playbook, or add it to roles_path in ansible.cfg. Then reference it:
cat site.yml
---
- name: Configure web servers
hosts: webservers
roles:
- role: my_nginx
vars:
nginx_port: 8080
nginx_server_name: example.com
Run it:
ansible-playbook -i inventory/hosts site.yml
Publishing to Ansible Galaxy
Ansible Galaxy uses your GitHub repository directly. The workflow is:
- Push your role to a public GitHub repo named
ansible-role-<rolename>. - Log in to galaxy.ansible.com with your GitHub account.
- Import the repository through the Galaxy web UI, or use the CLI:
ansible-galaxy role import yourghuser ansible-role-my_nginx --token YOUR_GALAXY_TOKEN
Others then install it with:
ansible-galaxy role install yourghuser.my_nginx
For team or project use, pin roles in a requirements.yml file and install from it:
cat requirements.yml
---
roles:
- name: yourghuser.my_nginx
version: "1.2.0"
- name: geerlingguy.firewall
version: "2.5.0"
ansible-galaxy role install -r requirements.yml
Verification
Run the playbook in check mode first to catch obvious errors without touching the target:
ansible-playbook -i inventory/hosts site.yml --check --diff
For proper role testing, use Molecule with a Docker or Podman driver. A basic test run after installing Molecule:
cd my_nginx
molecule init scenario --driver-name docker
molecule test
Troubleshooting
- Handler not firing: The
notifystring must match the handlernameexactly. Also confirm the notifying task actually reportedchanged—handlers only run on change. - Variable not overridable: If callers cannot override a value, it is probably in
vars/instead ofdefaults/. Move it todefaults/main.yml. - Role not found: Check
ansible-config dump | grep ROLES_PATHto see where Ansible is looking. Add your directory toroles_pathinansible.cfg. - Dependency loops: Circular role dependencies cause a hard error. Refactor shared logic into a standalone base role that both depend on.
- Template not updating: Ansible compares the rendered output, not the template source. Use
--diffto see exactly what changed, and confirm your Jinja2 variables have the expected values withansible -m debug -a "var=nginx_port" webservers.
Frequently asked questions
- What is the difference between import_tasks and include_tasks?
- import_tasks is static—Ansible processes it at parse time, so tags and conditionals on the import apply to every task inside. include_tasks is dynamic—it is evaluated at runtime, which allows looping over includes and using variables in the filename, but tags must be applied to individual tasks inside the file.
- Can a role be used more than once in the same play with different variables?
- Yes. List the role multiple times under the roles key with different var blocks. Ansible treats each entry as a separate invocation. You can also allow duplicate dependency runs by setting allow_duplicates: true in the role's meta/main.yml.
- Where should become: true live—in individual tasks or at the play level?
- Prefer setting become: true on individual tasks or the entire role call rather than the play level. This keeps privilege escalation explicit and auditable, and avoids elevating tasks that do not need it.
- How do I handle OS family differences inside a role?
- Use include_vars with a filename based on ansible_os_family or ansible_distribution, and place matching variable files like Debian.yml and RedHat.yml inside vars/. Override package names and service names per-family while keeping the task logic identical.
- Do I need Ansible Galaxy to share a role within my own organisation?
- No. You can host roles in any Git repository and install them via requirements.yml using the src key with a full Git URL and a version tag or commit SHA. Galaxy is only needed for public sharing.
Related guides
Configure Prometheus Alertmanager
Configure Prometheus Alertmanager with routing trees, receivers, inhibition rules, grouping, Go templates, and PagerDuty/Slack on-call integrations.
Build an Intranet Server on Linux
Set up a complete small-office intranet on one Linux box: Nginx web server, dnsmasq local DNS, Samba file sharing, and a Wiki.js team wiki.
Build an nftables Firewall Script
Build a complete nftables firewall from scratch: tables, chains, sets, default-deny input policy, service allowlisting, and persistent systemd configuration.
Caddy as a Reverse Proxy
Set up Caddy as a reverse proxy with automatic HTTPS, load balancing, WebSocket passthrough, reusable snippets, and header control — no certbot required.