$linuxjunkies
>

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.

IntermediateUbuntuDebianFedoraArch10 min readUpdated June 1, 2026

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:

  1. Push your role to a public GitHub repo named ansible-role-<rolename>.
  2. Log in to galaxy.ansible.com with your GitHub account.
  3. 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 notify string must match the handler name exactly. Also confirm the notifying task actually reported changed—handlers only run on change.
  • Variable not overridable: If callers cannot override a value, it is probably in vars/ instead of defaults/. Move it to defaults/main.yml.
  • Role not found: Check ansible-config dump | grep ROLES_PATH to see where Ansible is looking. Add your directory to roles_path in ansible.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 --diff to see exactly what changed, and confirm your Jinja2 variables have the expected values with ansible -m debug -a "var=nginx_port" webservers.
tested on:Ubuntu 24.04Fedora 40Debian 12Rocky 9

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