Devops
Difficulty: Advanced
14 min read

Advanced Ansible: Roles, Collections, AWX and CI/CD Pipelines

Complete advanced Ansible guide: best-practice roles, Galaxy collections, dynamic AWS/GCP inventories, AWX/Automation Controller, multi-distro Molecule and GitHub Actions + GitLab CI pipelines.

Back to tutorials
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)

Premium Content

This advanced tutorial is reserved for premium members.

9,90€ / month
  • All advanced tutorials
  • New content every week
  • Progress tracking
  • Cancel anytime

Written by

Morgann Riu

Cybersecurity and Linux administration expert. I share my knowledge through free tutorials and training to help system administrators and developers secure their infrastructures.

Frequently asked questions

What is the difference between defaults/main.yml and vars/main.yml in an Ansible role?
defaults/main.yml holds the role's default variables, with the lowest priority in the Ansible hierarchy. They can easily be overridden from the inventory, the playbook or the command line. vars/main.yml holds internal role variables with high priority, hard to override. The rule is simple: defaults/ for user-configurable variables (ports, paths, versions), vars/ for internal constants that must not change (package names per OS, fixed system paths). Never put secrets in either: use Ansible Vault.
How do you create and publish your own Ansible Galaxy collection?
Create the structure with ansible-galaxy collection init my_namespace.my_collection. Develop your roles, modules and plugins within the standard hierarchy. Define the semver version in galaxy.yml. Build with ansible-galaxy collection build, which generates a tarball. Test locally with ansible-galaxy collection install my_namespace-my_collection-1.0.0.tar.gz. Publish to Galaxy with ansible-galaxy collection publish --token the_tarball.tar.gz. For enterprise environments, use Automation Hub (Red Hat) or a private Pulp/Galaxy NG instance with the --server option.
AWX vs Ansible Tower vs Automation Controller: what are the differences?
These are three versions of the same product at different stages. AWX is the upstream open source version, community-developed, unsupported, with frequent releases. Ansible Tower was the Red Hat commercial product based on AWX, with paid support (version 3.x). Automation Controller is the current name of the commercial product in Red Hat Ansible Automation Platform 2.x. In practice: AWX for self-hosted environments with no budget, Automation Controller for enterprises that need Red Hat support and EDA (Event-Driven Ansible) integrations. The REST API is almost identical across all three.
How does Molecule work for testing Ansible roles?
Molecule orchestrates the full lifecycle of a role: creating the test environment (Docker instances, Vagrant VMs or cloud), running the role (converge), checking idempotence (idempotency), validation tests (verify), then teardown. The default scenario lives in molecule/default/. molecule.yml defines the driver (docker, vagrant, ec2) and the instances. converge.yml is the playbook that applies the role. verify.yml contains the assertions (Testinfra in Python or Ansible tasks). The complete workflow is molecule test, which chains all phases. For multi-distro CI, define several entries in platforms with different images.
How do you manage Ansible Vault secrets in a CI/CD pipeline?
The recommended practice is to store the Vault password in the CI/CD secrets system, never in the repository. In GitHub Actions, add ANSIBLE_VAULT_PASSWORD as a repository secret. In the workflow, export it to a temporary file: echo "${{ secrets.ANSIBLE_VAULT_PASSWORD }}" > .vault_pass and pass --vault-password-file .vault_pass to ansible-playbook. Delete the file after use with rm -f .vault_pass in a finally block. For multiple vault-ids (prod/staging), use several CI secrets and several --vault-id [email protected]_dev_pass --vault-id [email protected]_prod_pass. Never log sensitive variables: no_log: true on the relevant tasks.
What is the AWS EC2 dynamic inventory and how do you configure it?
The aws_ec2 dynamic inventory plugin queries the AWS API to automatically build the inventory of your EC2 instances, without a static hosts file. Install boto3 and the amazon.aws plugin. Create aws_ec2.yml with plugin: amazon.aws.aws_ec2, the regions and the filters. Ansible queries AWS on every run and builds groups from the EC2 tags (tag:Environment=prod becomes the group tag_Environment_prod). The keyed_groups section lets you create custom groups from any attribute. With constructed, you create complex conditional groups. AWS credentials are read from environment variables (AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY) or the ~/.aws/credentials profile.

Share this tutorial

Did you enjoy this article?

Comments

Checklist Sécurité Linux

30 points essentiels pour sécuriser un serveur Linux. Recevez aussi les nouveaux tutoriels par email.

Pas de spam. Désabonnement en 1 clic.