Ansible Roles and Galaxy: Structuring Automation at Scale
Why Roles Exist
When your automation grows beyond a handful of playbooks, you hit a wall. Playbooks start duplicating tasks, variables scatter across files, templates pile up in a single directory, and onboarding a new team member becomes a tour of tribal knowledge. A task block that installs and configures Nginx gets copy-pasted into three different playbooks. Someone changes the Nginx template in one playbook but forgets the others. Your infrastructure diverges, and debugging becomes a nightmare.
Roles solve this by packaging tasks, handlers, templates, files, variables, and metadata into a self-contained unit with a predictable directory structure. A role called nginx should install Nginx, configure it, and manage the service. A role called postgresql should do the same for PostgreSQL. When you write a playbook, you simply compose roles together like building blocks:
---
- name: Configure web tier
hosts: webservers
become: true
roles:
- common
- security_baseline
- nginx
- certbot
- monitoring_agent
This is cleaner, more testable, and far easier to maintain than a 500-line monolithic playbook. Each role has a single responsibility, its own tests, and its own documentation. New team members can understand the nginx role without reading the entire codebase.
When to Use a Role vs a Playbook
Not everything needs to be a role. Here is a practical decision framework:
| Scenario | Use a Role | Use a Playbook |
|---|---|---|
| Reused across multiple playbooks | Yes | No |
| Has configurable parameters | Yes | Maybe |
| Needs independent testing | Yes | No |
| One-off deployment procedure | No | Yes |
| Orchestration across multiple hosts | No | Yes |
| Shared with the community | Yes | No |
| Contains only a few tasks | Probably not | Yes |
A good rule of thumb: if you find yourself copying tasks between playbooks, extract them into a role.
Role Directory Structure
Every role follows the same convention. Ansible automatically loads files named main.yml from each subdirectory:
roles/
nginx/
tasks/
main.yml # Entry point for tasks
install.yml # Installation tasks (included from main)
configure.yml # Configuration tasks
ssl.yml # SSL/TLS setup tasks
handlers/
main.yml # Handler definitions
templates/
nginx.conf.j2 # Main Nginx config template
vhost.conf.j2 # Virtual host template
ssl.conf.j2 # SSL configuration template
files/
index.html # Static default page
nginx.logrotate # Logrotate config
vars/
main.yml # High-priority internal variables
Debian.yml # Debian-specific variables
RedHat.yml # RedHat-specific variables
defaults/
main.yml # Low-priority default values
meta/
main.yml # Role metadata and dependencies
tests/
inventory # Test inventory
test.yml # Test playbook
molecule/
default/ # Molecule test scenarios
README.md # Documentation
Directory Purpose Reference
| Directory | Purpose | When to Use |
|---|---|---|
defaults/ | Default values users are expected to override | Always -- every configurable value goes here |
vars/ | Internal variables the role needs to function | OS-specific package names, internal paths |
tasks/ | The actual automation logic | Always |
handlers/ | Service restarts and reloads triggered by notify | When managing services |
templates/ | Jinja2 files rendered with the template module | Configuration files with dynamic values |
files/ | Static files copied with the copy module | Files that never change (scripts, keys) |
meta/ | Dependencies, supported platforms, Galaxy metadata | Always -- even for internal roles |
tests/ | Basic test playbook | Minimal testing |
molecule/ | Full integration test scenarios | Proper CI testing |
The distinction between defaults/ and vars/ is important. Put anything a user might want to customize in defaults/main.yml. Put internal constants (like package names that differ by OS family) in vars/main.yml. If a value being overridden would break the role, it belongs in vars/, not defaults/.
Creating a Complete Role from Scratch
Scaffolding with ansible-galaxy init
ansible-galaxy init roles/nginx
This creates the full directory structure with placeholder main.yml files. Most roles do not need every directory, but having the structure in place makes it clear where things belong.
defaults/main.yml -- User-Facing Configuration
---
# Network
nginx_port: 80
nginx_ssl_port: 443
nginx_server_name: _
# Performance
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_multi_accept: "on"
nginx_sendfile: "on"
nginx_tcp_nopush: "on"
nginx_tcp_nodelay: "on"
nginx_keepalive_timeout: 65
nginx_types_hash_max_size: 2048
# Paths
nginx_document_root: /var/www/html
nginx_log_dir: /var/log/nginx
nginx_config_dir: /etc/nginx
# SSL
nginx_ssl_enabled: false
nginx_ssl_certificate: ""
nginx_ssl_certificate_key: ""
nginx_ssl_protocols: "TLSv1.2 TLSv1.3"
nginx_ssl_ciphers: "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256"
nginx_ssl_prefer_server_ciphers: "on"
# Security headers
nginx_add_security_headers: true
nginx_hsts_max_age: 31536000
# Logging
nginx_access_log_format: combined
nginx_error_log_level: warn
# Virtual hosts
nginx_vhosts: []
# Example:
# nginx_vhosts:
# - server_name: example.com
# document_root: /var/www/example.com
# ssl: true
# Gzip
nginx_gzip_enabled: true
nginx_gzip_types:
- text/plain
- text/css
- application/json
- application/javascript
- text/xml
- application/xml
- image/svg+xml
# Extra configuration
nginx_extra_http_options: ""
nginx_extra_server_options: ""
vars/main.yml -- Internal Constants
---
# Package names (should not be overridden by users)
nginx_package_name: nginx
nginx_service_name: nginx
nginx_user: www-data
nginx_group: www-data
OS-Specific Variables
# vars/Debian.yml
---
nginx_package_name: nginx
nginx_config_path: /etc/nginx/nginx.conf
nginx_default_site_path: /etc/nginx/sites-enabled/default
nginx_sites_available: /etc/nginx/sites-available
nginx_sites_enabled: /etc/nginx/sites-enabled
nginx_user: www-data
# vars/RedHat.yml
---
nginx_package_name: nginx
nginx_config_path: /etc/nginx/nginx.conf
nginx_default_site_path: /etc/nginx/conf.d/default.conf
nginx_sites_available: /etc/nginx/conf.d
nginx_sites_enabled: /etc/nginx/conf.d
nginx_user: nginx
tasks/main.yml -- The Entry Point
Split tasks into logical files and include them from main.yml:
---
- name: Load OS-specific variables
include_vars: "{{ ansible_os_family }}.yml"
- name: Include installation tasks
include_tasks: install.yml
tags:
- nginx
- nginx_install
- name: Include configuration tasks
include_tasks: configure.yml
tags:
- nginx
- nginx_configure
- name: Include SSL tasks
include_tasks: ssl.yml
when: nginx_ssl_enabled | bool
tags:
- nginx
- nginx_ssl
- name: Include virtual host tasks
include_tasks: vhosts.yml
when: nginx_vhosts | length > 0
tags:
- nginx
- nginx_vhosts
tasks/install.yml
---
- name: Install Nginx (Debian)
apt:
name: "{{ nginx_package_name }}"
state: present
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Install Nginx (RedHat)
dnf:
name: "{{ nginx_package_name }}"
state: present
when: ansible_os_family == "RedHat"
- name: Install SSL dependencies
apt:
name:
- openssl
- ssl-cert
state: present
when:
- ansible_os_family == "Debian"
- nginx_ssl_enabled | bool
- name: Create required directories
file:
path: "{{ item }}"
state: directory
owner: root
group: root
mode: "0755"
loop:
- "{{ nginx_config_dir }}/snippets"
- "{{ nginx_log_dir }}"
- "{{ nginx_document_root }}"
tasks/configure.yml
---
- name: Deploy main Nginx configuration
template:
src: nginx.conf.j2
dest: "{{ nginx_config_path }}"
owner: root
group: root
mode: "0644"
validate: "nginx -t -c %s"
notify: Reload Nginx
- name: Deploy security headers snippet
template:
src: security-headers.conf.j2
dest: "{{ nginx_config_dir }}/snippets/security-headers.conf"
owner: root
group: root
mode: "0644"
when: nginx_add_security_headers | bool
notify: Reload Nginx
- name: Remove default site
file:
path: "{{ nginx_default_site_path }}"
state: absent
notify: Reload Nginx
- name: Deploy default document root index
copy:
src: index.html
dest: "{{ nginx_document_root }}/index.html"
owner: "{{ nginx_user }}"
group: "{{ nginx_group }}"
mode: "0644"
force: false
- name: Deploy logrotate configuration
template:
src: logrotate.j2
dest: /etc/logrotate.d/nginx
owner: root
group: root
mode: "0644"
- name: Ensure Nginx is started and enabled
service:
name: "{{ nginx_service_name }}"
state: started
enabled: true
tasks/vhosts.yml
---
- name: Deploy virtual host configurations
template:
src: vhost.conf.j2
dest: "{{ nginx_sites_available }}/{{ item.server_name }}.conf"
owner: root
group: root
mode: "0644"
loop: "{{ nginx_vhosts }}"
notify: Reload Nginx
- name: Enable virtual hosts (Debian)
file:
src: "{{ nginx_sites_available }}/{{ item.server_name }}.conf"
dest: "{{ nginx_sites_enabled }}/{{ item.server_name }}.conf"
state: link
loop: "{{ nginx_vhosts }}"
when: ansible_os_family == "Debian"
notify: Reload Nginx
- name: Create document roots for virtual hosts
file:
path: "{{ item.document_root | default(nginx_document_root) }}"
state: directory
owner: "{{ nginx_user }}"
group: "{{ nginx_group }}"
mode: "0755"
loop: "{{ nginx_vhosts }}"
handlers/main.yml
---
- name: Reload Nginx
service:
name: "{{ nginx_service_name }}"
state: reloaded
listen: "Reload Nginx"
- name: Restart Nginx
service:
name: "{{ nginx_service_name }}"
state: restarted
listen: "Restart Nginx"
- name: Validate Nginx config
command: nginx -t
changed_when: false
listen: "Validate Nginx config"
meta/main.yml
---
galaxy_info:
author: yourname
description: Install and configure Nginx web server
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Ubuntu
versions:
- jammy
- noble
- name: Debian
versions:
- bookworm
- trixie
- name: EL
versions:
- "8"
- "9"
galaxy_tags:
- web
- nginx
- webserver
- reverse_proxy
dependencies:
- role: common
when: include_common_role | default(true) | bool
Using Roles in Playbooks
There are several ways to include roles in your playbooks:
Classic roles Section
---
- name: Configure web tier
hosts: webservers
become: true
roles:
- common
- role: nginx
vars:
nginx_port: 8080
nginx_ssl_enabled: true
- role: certbot
when: enable_ssl | default(false)
Using include_role for Conditional Loading
---
- name: Configure servers
hosts: all
become: true
tasks:
- name: Apply base security
include_role:
name: security_baseline
- name: Configure web server role
include_role:
name: nginx
when: "'webservers' in group_names"
- name: Configure database role
include_role:
name: postgresql
when: "'dbservers' in group_names"
vars:
postgresql_version: 16
postgresql_max_connections: 200
Using import_role for Static Inclusion
- name: Always include monitoring
import_role:
name: monitoring_agent
tags: monitoring
The difference between include_role and import_role is important:
| Feature | include_role | import_role |
|---|---|---|
| Processing | Dynamic (at runtime) | Static (at parse time) |
| Tags | Tags apply to the include task only | Tags apply to all tasks in the role |
| Loops | Supports loops | Does not support loops |
| Conditional | Evaluated at runtime | Evaluated at parse time |
| Handlers | Can trigger handlers | Can trigger handlers |
| Performance | Slightly slower | Slightly faster |
Use import_role when you need tags to propagate into the role. Use include_role when you need dynamic behavior (loops, conditionals evaluated at runtime).
Using Roles from Ansible Galaxy
Galaxy is the community hub for sharing roles. Install a role:
ansible-galaxy install geerlingguy.docker
This downloads the role to ~/.ansible/roles/ by default. To install into your project directory:
ansible-galaxy install geerlingguy.docker -p roles/
Browsing and Evaluating Galaxy Roles
Before adopting a community role, evaluate it:
# Search for roles
ansible-galaxy search nginx --platforms Ubuntu
# View role info
ansible-galaxy info geerlingguy.nginx
Look for these quality signals:
| Signal | What to Check |
|---|---|
| Downloads | Higher is better, indicates community trust |
| GitHub stars | Community endorsement |
| Last commit | Actively maintained? |
| CI status | Are tests passing? |
| Platforms | Does it support your OS? |
| Molecule tests | Does it have proper test coverage? |
| Variables | Are defaults well documented? |
requirements.yml for Reproducible Builds
For reproducible builds, pin your role and collection dependencies in a requirements.yml file:
---
roles:
- name: geerlingguy.docker
version: "7.4.1"
- name: geerlingguy.certbot
version: "5.1.0"
- name: geerlingguy.nginx
version: "3.2.0"
- name: dev-sec.os-hardening
version: "7.0.0"
# Install from a private Git repo
- name: custom_nginx
src: git+ssh://git@github.com/yourorg/ansible-role-custom-nginx.git
version: v2.1.0
scm: git
# Install from a tarball
- name: legacy_role
src: https://internal.example.com/roles/legacy_role-1.0.tar.gz
collections:
- name: amazon.aws
version: ">=7.0.0"
- name: community.general
version: ">=9.0.0"
- name: community.docker
version: ">=3.0.0"
- name: ansible.posix
version: ">=1.5.0"
Install all dependencies:
ansible-galaxy install -r requirements.yml
# Install roles to a specific directory
ansible-galaxy install -r requirements.yml -p roles/
# Force reinstall to ensure pinned versions
ansible-galaxy install -r requirements.yml --force
# Install collections
ansible-galaxy collection install -r requirements.yml
# Install everything (roles + collections)
ansible-galaxy install -r requirements.yml
ansible-galaxy collection install -r requirements.yml
In CI/CD pipelines, always use --force to ensure you get the exact pinned version:
ansible-galaxy install -r requirements.yml -p roles/ --force
ansible-galaxy collection install -r requirements.yml --force
Role Dependencies
Define dependencies in meta/main.yml so that prerequisite roles run automatically:
---
galaxy_info:
author: yourname
description: Full web application stack
license: MIT
min_ansible_version: "2.14"
platforms:
- name: Ubuntu
versions:
- jammy
- noble
dependencies:
- role: common
- role: geerlingguy.certbot
vars:
certbot_auto_renew: true
certbot_auto_renew_hour: 3
certbot_auto_renew_minute: 30
when: nginx_ssl_enabled | default(false)
- role: geerlingguy.nginx
vars:
nginx_remove_default_vhost: true
When Ansible runs a role with dependencies, it first runs all dependency roles in the order listed. Dependencies are deduplicated by default: if two roles both depend on common, it runs once. You can override this with allow_duplicates: true in meta/main.yml, but this is rarely needed.
Handling Circular Dependencies
If role A depends on role B and role B depends on role A, Ansible will detect the cycle and fail. Restructure by extracting the shared logic into a third role that both A and B depend on.
Variable Precedence in Roles
Understanding where to put variables prevents hours of debugging. Here is the precedence from lowest to highest:
- Role
defaults/main.yml(designed to be overridden) - Inventory file or script group variables
- Inventory
group_vars/all - Inventory
group_vars/* - Inventory file or script host variables
- Inventory
host_vars/* - Play
vars: - Play
vars_files: - Role
vars/main.yml(internal to the role) - Block
vars: - Task
vars:(only for the task) include_varsset_fact/register- Role and include role parameters
- Extra variables (
-eon CLI) -- always wins
The practical rules:
- If a user should be able to change a value by setting an inventory variable, put it in
defaults/. - If the role breaks when someone overrides a value, put it in
vars/. - If you need to compute a value at runtime, use
set_fact. - Extra variables (
-e) override everything, which makes them ideal for CI/CD overrides.
Collections vs Roles
Starting with Ansible 2.10, collections became the primary distribution mechanism. A collection can contain roles, modules, plugins, and playbooks in a single package with proper namespacing.
| Feature | Roles | Collections |
|---|---|---|
| Contains | Tasks, handlers, templates, files, vars | Roles + modules + plugins + playbooks |
| Namespace | Flat (geerlingguy.docker) | Fully namespaced (community.docker) |
| Install command | ansible-galaxy install | ansible-galaxy collection install |
| Distribution | Galaxy, Git, tarball | Galaxy, Automation Hub, Git |
| Module bundling | Cannot include modules | Can include custom modules |
| Version conflicts | Name collisions possible | Namespacing prevents conflicts |
Many Galaxy roles have migrated to collections. For example, AWS modules moved from standalone roles to the amazon.aws collection, and Docker modules moved to community.docker:
ansible-galaxy collection install amazon.aws
ansible-galaxy collection install community.docker
ansible-galaxy collection install community.general
Your requirements.yml can include both roles and collections in a single file:
---
roles:
- name: geerlingguy.docker
version: "7.4.1"
collections:
- name: amazon.aws
version: ">=7.0.0"
- name: community.general
version: ">=9.0.0"
- name: community.docker
version: ">=3.0.0"
Using Collection Modules in Roles
When your role uses modules from a collection, declare the dependency:
# meta/main.yml
collections:
- community.general
- ansible.posix
dependencies: []
Then reference modules by their fully qualified collection name (FQCN):
# tasks/main.yml
- name: Manage SELinux
ansible.posix.selinux:
state: enforcing
policy: targeted
- name: Install pip packages
community.general.pip:
name: docker
state: present
Testing Roles with Molecule
Molecule is the standard framework for testing Ansible roles. It creates disposable infrastructure (Docker containers, Vagrant VMs, or cloud instances), applies your role, runs idempotence checks, and executes verification tests.
Installation
pip install molecule molecule-docker ansible-lint yamllint
Initialize Molecule in an Existing Role
cd roles/nginx
molecule init scenario --driver-name docker
This creates a molecule/default/ directory:
molecule/
default/
converge.yml # Playbook that applies the role
molecule.yml # Molecule configuration
verify.yml # Test assertions
prepare.yml # Pre-test setup (optional)
cleanup.yml # Post-test cleanup (optional)
side_effect.yml # Simulate failures (optional)
molecule.yml -- Complete Configuration
---
dependency:
name: galaxy
options:
requirements-file: requirements.yml
driver:
name: docker
platforms:
- name: ubuntu-jammy
image: ubuntu:22.04
pre_build_image: true
command: /sbin/init
privileged: true
tmpfs:
- /run
- /tmp
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
- name: ubuntu-noble
image: ubuntu:24.04
pre_build_image: true
command: /sbin/init
privileged: true
tmpfs:
- /run
- /tmp
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
- name: debian-bookworm
image: debian:bookworm
pre_build_image: true
command: /sbin/init
privileged: true
tmpfs:
- /run
- /tmp
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
provisioner:
name: ansible
playbooks:
converge: converge.yml
verify: verify.yml
inventory:
group_vars:
all:
nginx_port: 8080
nginx_ssl_enabled: false
lint:
name: ansible-lint
verifier:
name: ansible
lint: |
set -e
yamllint .
ansible-lint
converge.yml -- Apply the Role
---
- name: Converge
hosts: all
become: true
pre_tasks:
- name: Update apt cache (Debian)
apt:
update_cache: true
when: ansible_os_family == "Debian"
changed_when: false
roles:
- role: nginx
vars:
nginx_port: 8080
nginx_vhosts:
- server_name: test.example.com
document_root: /var/www/test
verify.yml -- Comprehensive Test Assertions
---
- name: Verify
hosts: all
become: true
tasks:
- name: Check Nginx package is installed
package:
name: nginx
state: present
check_mode: true
register: nginx_pkg
failed_when: nginx_pkg.changed
- name: Check Nginx is installed
command: nginx -v
register: nginx_version
changed_when: false
- name: Verify Nginx service is running
service_facts:
- name: Assert Nginx service is active
assert:
that:
- "'nginx.service' in ansible_facts.services or 'nginx' in ansible_facts.services"
fail_msg: "Nginx service is not running"
- name: Check Nginx is listening on configured port
wait_for:
port: 8080
timeout: 10
- name: Verify Nginx responds with 200
uri:
url: http://localhost:8080/
status_code: 200
register: http_response
- name: Check Nginx config syntax is valid
command: nginx -t
register: nginx_test
changed_when: false
- name: Verify document root exists
stat:
path: /var/www/test
register: docroot
- name: Assert document root is a directory
assert:
that:
- docroot.stat.exists
- docroot.stat.isdir
- docroot.stat.pw_name == 'www-data'
- name: Verify logrotate config exists
stat:
path: /etc/logrotate.d/nginx
register: logrotate_conf
- name: Assert logrotate config exists
assert:
that:
- logrotate_conf.stat.exists
Running Molecule Tests
# Full test lifecycle: lint, create, converge, idempotence, verify, destroy
molecule test
# Step by step for development
molecule create # Spin up containers
molecule converge # Apply role
molecule idempotence # Run again, verify no changes
molecule verify # Run test assertions
molecule login -h ubuntu-jammy # SSH into specific container for debugging
molecule destroy # Tear down all containers
# Run only specific stages
molecule converge && molecule verify
# Test with a specific scenario
molecule test -s my_scenario
# Run with debug output
molecule --debug test
Testing Multiple Scenarios
Create additional scenarios for different configurations:
molecule init scenario --scenario-name ssl --driver-name docker
# molecule/ssl/converge.yml
---
- name: Converge SSL scenario
hosts: all
become: true
roles:
- role: nginx
vars:
nginx_ssl_enabled: true
nginx_ssl_certificate: /etc/ssl/certs/test.crt
nginx_ssl_certificate_key: /etc/ssl/private/test.key
# Run a specific scenario
molecule test -s ssl
Integrating Molecule with CI/CD
# .github/workflows/molecule.yml
name: Molecule Tests
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
molecule:
runs-on: ubuntu-latest
strategy:
matrix:
scenario:
- default
- ssl
fail-fast: false
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- name: Install dependencies
run: |
pip install ansible molecule molecule-docker ansible-lint yamllint
- name: Run Molecule
run: molecule test -s ${{ matrix.scenario }}
working-directory: roles/nginx
env:
PY_COLORS: "1"
ANSIBLE_FORCE_COLOR: "1"
GitLab CI Integration
# .gitlab-ci.yml
stages:
- lint
- test
ansible-lint:
stage: lint
image: python:3.12
script:
- pip install ansible-lint yamllint
- yamllint .
- ansible-lint
molecule:
stage: test
image: docker:latest
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2375
before_script:
- apk add --no-cache python3 py3-pip
- pip install ansible molecule molecule-docker --break-system-packages
script:
- cd roles/nginx
- molecule test
Publishing to Galaxy
If your role is generic enough to share, publish it:
- Ensure
meta/main.ymlhas complete Galaxy metadata - Host the role in a public GitHub repository named
ansible-role-rolename - Log in to Galaxy and import the repository
# Import from GitHub
ansible-galaxy role import yourGithubUsername ansible-role-nginx
# Delete a role from Galaxy
ansible-galaxy role delete yourGithubUsername nginx
Galaxy automatically detects the role name from the repository name (stripping the ansible-role- prefix). Make sure your README includes:
- A description of what the role does
- All variables from
defaults/main.ymlwith descriptions - Example playbook usage
- Supported platforms and Ansible versions
- A link to the Molecule test results (CI badge)
Best Practices for Role Design
Keep roles focused. One role should do one thing. An nginx role installs and configures Nginx. It should not also deploy your application code or configure your monitoring stack. That belongs in separate roles.
Use defaults for everything configurable. A well-designed role works out of the box with zero user-supplied variables. Every variable in defaults/main.yml should have a sensible default value that produces a working result.
Prefix all variables. Every variable in a role must be prefixed with the role name to avoid collisions:
# Correct: namespaced to the role
nginx_port: 80
nginx_worker_processes: auto
nginx_ssl_enabled: false
# Wrong: will collide with other roles
port: 80
worker_processes: auto
ssl_enabled: false
Support multiple platforms. Use ansible_os_family or ansible_distribution conditionals, or load platform-specific variables:
# tasks/main.yml
- name: Load OS-specific variables
include_vars: "{{ ansible_os_family }}.yml"
Validate inputs. Add assertions at the beginning of your role to catch misconfiguration early:
# tasks/main.yml (at the top)
- name: Validate role inputs
assert:
that:
- nginx_port | int > 0
- nginx_port | int < 65536
- nginx_worker_processes == 'auto' or nginx_worker_processes | int > 0
fail_msg: "Invalid nginx configuration values"
Tag your tasks. Tags make it possible to run subsets of a role:
- name: Install Nginx
apt:
name: nginx
state: present
tags:
- nginx
- packages
- nginx_install
Idempotency is non-negotiable. Running the role twice must produce the same result with zero changes on the second run. Avoid command and shell modules when a purpose-built module exists. If you must use them, add creates, removes, or changed_when to ensure idempotency:
# Bad: not idempotent
- name: Initialize database
command: /opt/myapp/bin/init-db
# Good: idempotent with creates
- name: Initialize database
command: /opt/myapp/bin/init-db
args:
creates: /opt/myapp/data/.initialized
Use handlers correctly. Never put service restarts directly in tasks. Always use handlers so that multiple configuration changes result in a single restart:
# Bad: restarts on every run if config changed
- name: Deploy config
template:
src: app.conf.j2
dest: /etc/app/app.conf
- name: Restart app
service:
name: app
state: restarted
# Good: handler only fires if template changed
- name: Deploy config
template:
src: app.conf.j2
dest: /etc/app/app.conf
notify: Restart app
Document your role. The README.md should list all variables from defaults/main.yml, example playbook usage, and supported platforms. If someone cannot use your role by reading the README alone, the documentation is incomplete. Include a table of all defaults:
| Variable | Default | Description |
|----------|---------|-------------|
| `nginx_port` | `80` | HTTP listen port |
| `nginx_ssl_enabled` | `false` | Enable SSL/TLS |
| `nginx_worker_processes` | `auto` | Number of worker processes |
Troubleshooting Common Role Issues
Role Not Found
# Check where Ansible looks for roles
ansible-config dump | grep ROLES_PATH
# Typical search order:
# 1. roles/ relative to the playbook
# 2. ~/.ansible/roles
# 3. /usr/share/ansible/roles
# 4. /etc/ansible/roles
# Set custom path in ansible.cfg
# [defaults]
# roles_path = ./roles:~/.ansible/roles
Variable Not Being Overridden
If setting a variable in your inventory does not override a role default, check precedence. The most common mistake is putting a value in vars/main.yml (high priority) instead of defaults/main.yml (low priority).
# Debug variable precedence
ansible-playbook site.yml -e "debug_vars=true" -vvv
Handler Not Running
Handlers only run when a task reports changed. If the task reports ok (no change), the handler is not notified. Use --diff to see what changed:
ansible-playbook site.yml --diff
To force a handler to run regardless:
- name: Force handler notification
command: echo "forcing handler"
changed_when: true
notify: Restart Nginx
Related Articles
Ansible Fundamentals: Your First Playbook to Production
Learn Ansible from scratch — install Ansible, write your first playbook, understand modules, manage inventories, and automate server configuration.
Ansible Dynamic Inventory: Automating Cloud Infrastructure
Use dynamic inventories to automatically discover and manage cloud infrastructure — AWS EC2, Azure VMs, and GCP instances with Ansible inventory plugins.
Ansible Vault: Encrypting Secrets in Your Automation
Encrypt sensitive data with Ansible Vault — encrypt files, inline variables, multi-password setups, and integrate with external secret managers like HashiCorp Vault.