DevOpsil
Ansible
91%
Fresh

Ansible Roles and Galaxy: Structuring Automation at Scale

Dev PatelDev Patel20 min read

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:

ScenarioUse a RoleUse a Playbook
Reused across multiple playbooksYesNo
Has configurable parametersYesMaybe
Needs independent testingYesNo
One-off deployment procedureNoYes
Orchestration across multiple hostsNoYes
Shared with the communityYesNo
Contains only a few tasksProbably notYes

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

DirectoryPurposeWhen to Use
defaults/Default values users are expected to overrideAlways -- every configurable value goes here
vars/Internal variables the role needs to functionOS-specific package names, internal paths
tasks/The actual automation logicAlways
handlers/Service restarts and reloads triggered by notifyWhen managing services
templates/Jinja2 files rendered with the template moduleConfiguration files with dynamic values
files/Static files copied with the copy moduleFiles that never change (scripts, keys)
meta/Dependencies, supported platforms, Galaxy metadataAlways -- even for internal roles
tests/Basic test playbookMinimal testing
molecule/Full integration test scenariosProper 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:

Featureinclude_roleimport_role
ProcessingDynamic (at runtime)Static (at parse time)
TagsTags apply to the include task onlyTags apply to all tasks in the role
LoopsSupports loopsDoes not support loops
ConditionalEvaluated at runtimeEvaluated at parse time
HandlersCan trigger handlersCan trigger handlers
PerformanceSlightly slowerSlightly 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:

SignalWhat to Check
DownloadsHigher is better, indicates community trust
GitHub starsCommunity endorsement
Last commitActively maintained?
CI statusAre tests passing?
PlatformsDoes it support your OS?
Molecule testsDoes it have proper test coverage?
VariablesAre 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:

  1. Role defaults/main.yml (designed to be overridden)
  2. Inventory file or script group variables
  3. Inventory group_vars/all
  4. Inventory group_vars/*
  5. Inventory file or script host variables
  6. Inventory host_vars/*
  7. Play vars:
  8. Play vars_files:
  9. Role vars/main.yml (internal to the role)
  10. Block vars:
  11. Task vars: (only for the task)
  12. include_vars
  13. set_fact / register
  14. Role and include role parameters
  15. Extra variables (-e on 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.

FeatureRolesCollections
ContainsTasks, handlers, templates, files, varsRoles + modules + plugins + playbooks
NamespaceFlat (geerlingguy.docker)Fully namespaced (community.docker)
Install commandansible-galaxy installansible-galaxy collection install
DistributionGalaxy, Git, tarballGalaxy, Automation Hub, Git
Module bundlingCannot include modulesCan include custom modules
Version conflictsName collisions possibleNamespacing 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:

  1. Ensure meta/main.yml has complete Galaxy metadata
  2. Host the role in a public GitHub repository named ansible-role-rolename
  3. 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.yml with 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
Share:
Dev Patel
Dev Patel

Cloud Cost Optimization Specialist

I find the money your cloud is wasting. FinOps practitioner, data-driven analyst, and the person your CFO wishes they'd hired sooner. Every dollar saved is a dollar earned.

Related Articles