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.

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.