Key takeaways
- The most misunderstood rule in Ansible roles:
defaults/main.ymlhas the lowest precedence and holds the variables users must be able to change (ports, paths, versions);vars/main.ymlhas high precedence and holds internal constants (per-OS package names, fixed system paths). No secret belongs in either — that is what Ansible Vault is for. - A collection is the modern distribution format: it packages roles, modules, plugins and playbooks into a semver-versioned artefact —
ansible.builtinis one itself. Declare everything inrequirements.ymlwith bounds (amazon.aws: ">=7.0.0,<8.0.0") and install it inside the project withansible-galaxy collection install -r requirements.yml -p ./collectionsto pin versions per repository. - AWX, Ansible Tower and Automation Controller are three states of the same product: AWX is the unsupported open source upstream, Tower was the 3.x commercial product, and Automation Controller is its current name in Ansible Automation Platform 2.x. The REST API is nearly identical, which lets you configure AWX as code with the
awx.awxcollection orawxkit(awx job_templates launch --monitor 42). molecule testruns the full cycle create → prepare → converge → idempotency → verify → destroy: the idempotency phase replays the role and fails if it still reports changes, which is the real test of a role. Multi-distro coverage comes fromMOLECULE_DISTRO=rocky9 molecule testlocally and a role × distro matrix in CI.- In CI/CD the Vault password lives in the runner's secret store, never in the repository:
echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault_pass, then--vault-password-file .vault_passandrm -f .vault_passin anafter_script. Addno_log: trueto every task handling a secret; since the vault-id is written into the$ANSIBLE_VAULT;1.2;AES256;prodheader, Ansible picks the right key on its own.
This guide assumes solid knowledge of Ansible (playbooks, inventories, modules). If you are just starting out, begin with the Ansible introduction guide. For this tutorial: Ansible Core 2.15+, Python 3.10+, Docker installed locally.
Why basic Ansible isn't enough at scale
Ansible is appealing in its simplest form: a hosts file, a YAML playbook, and you're done. That simplicity, however, hides structural limitations that become painful as the infrastructure grows.
The symptoms are always the same. 1500-line playbooks that are impossible to maintain. Variables copy-pasted across ten projects. No way to know which version of a role is deployed in production. No way to test a change before pushing it to prod. Plaintext secrets in the git repository. And zero visibility into who ran what and when.
This guide covers the solutions to each of these problems:
- Advanced roles: professional structure, variables, dependencies, tags
- Collections: packaging, versioning, Galaxy vs Automation Hub
- Dynamic inventories: AWS EC2, GCP, constructed groups
- AWX: web interface, RBAC, graphical workflows, schedules
- CI/CD: lint, Molecule testing, automated deployment
- Security: advanced Vault, CI/CD secrets, no_log
1. Advanced roles
Best-practice structure of a role
A well-structured role is a self-contained, testable and reusable component. The ansible-galaxy role init command creates the standard skeleton, which you can extend as needed.
# Create a role with the full structure
ansible-galaxy role init my_role
# Generated structure
my_role/
├── README.md # Mandatory documentation
├── meta/
│ └── main.yml # Metadata and dependencies
├── defaults/
│ └── main.yml # Default variables (low priority)
├── vars/
│ └── main.yml # Internal variables (high priority)
├── tasks/
│ ├── main.yml # Task entry point
│ ├── install.yml # Installation tasks
│ ├── configure.yml # Configuration tasks
│ └── debian.yml # Debian-specific tasks
├── handlers/
│ └── main.yml # Handlers (restart service, reload config)
├── templates/
│ └── nginx.conf.j2 # Jinja2 templates
├── files/
│ └── logrotate.conf # Static files
├── tests/
│ ├── inventory # Minimal test inventory
│ └── test.yml # Basic test playbook
└── molecule/ # Molecule tests (see section 7)
└── default/
defaults/main.yml vs vars/main.yml
The distinction between these two directories is fundamental and often misunderstood. It determines who can override which variable and at what level.
# defaults/main.yml — User-configurable variables
# LOWEST priority → easily overridden
nginx_port: 80
nginx_ssl_port: 443
nginx_worker_processes: auto
nginx_user: www-data
nginx_log_path: /var/log/nginx
nginx_sites_available: /etc/nginx/sites-available
nginx_sites_enabled: /etc/nginx/sites-enabled
# These values can be overridden from:
# - group_vars/, host_vars/
# - -e "nginx_port=8080" on the command line
# - The calling playbook
# vars/main.yml — Internal role constants
# HIGH priority → hard to override (advanced use only)
_nginx_packages:
Debian:
- nginx
- nginx-extras
RedHat:
- nginx
- nginx-mod-http-geoip2
_nginx_service_name: nginx
_nginx_config_dir: /etc/nginx
_nginx_pid_file: /run/nginx.pid
# Rule: never put secrets here, only structural constants
# tasks/main.yml — Entry point with conditional include
---
- name: Include variables based on the OS
ansible.builtin.include_vars: "{{ ansible_os_family }}.yml"
tags: always
- name: Install Nginx
ansible.builtin.include_tasks: install.yml
tags: [nginx, install]
- name: Configure Nginx
ansible.builtin.include_tasks: configure.yml
tags: [nginx, configure]
- name: Enable and start the service
ansible.builtin.service:
name: "{{ _nginx_service_name }}"
state: started
enabled: true
tags: [nginx, service]
meta/main.yml: dependencies and metadata
The meta/main.yml file is crucial for two reasons: it documents the role for Galaxy and defines its dependencies, which are resolved automatically.
# meta/main.yml
galaxy_info:
author: your_handle
description: Production-ready Nginx role with SSL and hardening
company: My Company
license: MIT
min_ansible_version: "2.15"
platforms:
- name: Ubuntu
versions:
- "22.04"
- "24.04"
- name: Debian
versions:
- "12"
- name: EL
versions:
- "9"
galaxy_tags:
- nginx
- web
- proxy
- ssl
# Dependencies: these roles will run BEFORE this one
dependencies:
- role: geerlingguy.certbot
vars:
certbot_email: [email protected]
when: nginx_ssl_enabled | bool
- role: common.firewall
vars:
firewall_allowed_tcp_ports:
- "{{ nginx_port }}"
- "{{ nginx_ssl_port }}"
handlers/main.yml
# handlers/main.yml
---
- name: Restart nginx
ansible.builtin.service:
name: "{{ _nginx_service_name }}"
state: restarted
listen: restart nginx
- name: Reload nginx
ansible.builtin.service:
name: "{{ _nginx_service_name }}"
state: reloaded
listen: reload nginx
- name: Test nginx config
ansible.builtin.command: nginx -t
changed_when: false
listen: test nginx config
Dynamic include_role and conditional when
The include_role directive lets you dynamically include roles inside a play, with conditions and variables computed at runtime — impossible with static roles:.
# playbook with dynamic include_role
---
- name: Configure web servers
hosts: webservers
become: true
tasks:
- name: Install Nginx or Apache depending on the group
ansible.builtin.include_role:
name: "{{ 'nginx' if 'nginx_servers' in group_names else 'apache2' }}"
vars:
web_port: 80
- name: Configure SSL only in production
ansible.builtin.include_role:
name: ssl_termination
when: ansible_env.ENVIRONMENT | default('dev') == 'prod'
- name: Apply monitoring roles for each service
ansible.builtin.include_role:
name: "monitoring_{{ item }}"
loop:
- nginx
- php_fpm
- postgresql
when: monitoring_enabled | default(true)
Premium Content
This advanced tutorial is reserved for premium members.
- All advanced tutorials
- New content every week
- Progress tracking
- Cancel anytime