Prerequisites
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)
Comments