DevOpsIndex

Ansible Advanced

Roles, collections, Ansible Vault, AWX/Tower, performance tuning, and testing strategies.


Roles

A role is a reusable, self-contained unit of automation. Instead of one giant playbook, you split work into roles and compose them.

Role Directory Structure

roles/
└── nginx/
    ├── defaults/
    │   └── main.yml        # lowest precedence variables (overridable)
    ├── vars/
    │   └── main.yml        # higher precedence variables (rarely overridden)
    ├── tasks/
    │   ├── main.yml        # entry point — import other task files here
    │   ├── install.yml
    │   └── configure.yml
    ├── handlers/
    │   └── main.yml        # handlers for this role
    ├── templates/
    │   └── nginx.conf.j2   # Jinja2 templates
    ├── files/
    │   └── index.html      # static files
    ├── meta/
    │   └── main.yml        # role metadata + dependencies
    └── README.md

Role: defaults/main.yml

# roles/nginx/defaults/main.yml
nginx_user: www-data
nginx_worker_processes: "{{ ansible_processor_count }}"
nginx_worker_connections: 1024
nginx_port: 80
ssl_enabled: false
ssl_cert_path: /etc/ssl/certs/nginx.crt
ssl_key_path: /etc/ssl/private/nginx.key

Role: tasks/main.yml

# roles/nginx/tasks/main.yml
---
- name: Include OS-specific variables
  ansible.builtin.include_vars: "{{ ansible_os_family | lower }}.yml"

- name: Install nginx
  ansible.builtin.import_tasks: install.yml

- name: Configure nginx
  ansible.builtin.import_tasks: configure.yml

- name: Start nginx
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

Role: tasks/configure.yml

# roles/nginx/tasks/configure.yml
---
- name: Create nginx config directory
  ansible.builtin.file:
    path: /etc/nginx/conf.d
    state: directory
    mode: "0755"

- name: Deploy main nginx config
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    mode: "0644"
    validate: nginx -t -c %s      # validate before replacing
  notify: Reload nginx

- name: Deploy site configs
  ansible.builtin.template:
    src: "site.conf.j2"
    dest: "/etc/nginx/conf.d/{{ item.name }}.conf"
    mode: "0644"
  loop: "{{ nginx_vhosts }}"
  notify: Reload nginx
  when: nginx_vhosts is defined

Role: meta/main.yml

# roles/nginx/meta/main.yml
galaxy_info:
  author: yourname
  description: Install and configure nginx
  license: MIT
  min_ansible_version: "2.14"
  platforms:
    - name: Ubuntu
      versions: ["22.04", "20.04"]
    - name: EL
      versions: ["8", "9"]

dependencies:
  - role: common            # runs before this role
  - role: ssl_certs
    vars:
      domain: "{{ app_domain }}"
    when: ssl_enabled

Using Roles in a Playbook

# site.yml
---
- name: Configure web servers
  hosts: web
  become: true

  roles:
    - common                    # simple role reference
    - role: nginx               # explicit form
      vars:
        nginx_port: 443
        ssl_enabled: true
    - role: myapp
      tags: app                 # all tasks in role get this tag

  # roles run before tasks
  tasks:
    - name: Final smoke test
      ansible.builtin.uri:
        url: "http://localhost/health"

Creating a Role with ansible-galaxy

ansible-galaxy role init roles/nginx
ansible-galaxy role init roles/myapp --offline

Collections

Collections are the packaging format for distributing roles, modules, plugins, and playbooks together. Introduced in Ansible 2.9.

namespace.collection_name
amazon.aws         # AWS modules (official)
google.cloud       # GCP modules (official)
community.general  # hundreds of community modules
ansible.posix      # POSIX-focused modules (mount, sysctl, etc.)

Installing Collections

# From Ansible Galaxy
ansible-galaxy collection install amazon.aws
ansible-galaxy collection install community.general

# Pin version
ansible-galaxy collection install amazon.aws:==7.0.0

# From requirements file
ansible-galaxy collection install -r requirements.yml
# requirements.yml
collections:
  - name: amazon.aws
    version: ">=7.0.0"
  - name: google.cloud
    version: ">=1.3.0"
  - name: community.general
  - name: ansible.posix

roles:
  - name: geerlingguy.nginx
    version: "3.1.0"
# Install all requirements
ansible-galaxy install -r requirements.yml
ansible-galaxy collection install -r requirements.yml

Using Collection Modules

# FQCN (Fully Qualified Collection Name) — always preferred
- amazon.aws.ec2_instance:
    name: web01

# Short name (only works if collection is in ansible.cfg)
- ec2_instance:
    name: web01
# ansible.cfg — set collection search path
[defaults]
collections_paths = ./collections:~/.ansible/collections

Ansible Vault

Vault encrypts sensitive data at rest. Encrypted files live in your repo — secrets never travel in plaintext.

flowchart TD
    A[Plain secret: db_password=s3cr3t] -->|ansible-vault encrypt| B[AES256 encrypted blob]
    B -->|commit to git| C[Repository]
    C -->|ansible-playbook --vault-password-file| D[Decrypt at runtime]
    D --> E[Variable available in play]

Encrypting Files

# Encrypt an entire file
ansible-vault encrypt group_vars/all/vault.yml

# Create new encrypted file
ansible-vault create group_vars/production/vault.yml

# Edit encrypted file (opens $EDITOR)
ansible-vault edit group_vars/production/vault.yml

# View without decrypting to disk
ansible-vault view group_vars/production/vault.yml

# Re-key (change password)
ansible-vault rekey group_vars/production/vault.yml

# Decrypt to plain text
ansible-vault decrypt group_vars/production/vault.yml

Encrypting Individual Values (inline)

# Encrypt a single string
ansible-vault encrypt_string 's3cr3tpassword' --name 'db_password'

# Output — paste into vars file
db_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  3036343832353065363465383834306462623035313630363261353666323766313865313164626233
  ...
# group_vars/production/vault.yml
vault_db_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  303634383235306536346538383430...

# group_vars/production/vars.yml (plain — references vault var)
db_password: "{{ vault_db_password }}"

Running Playbooks with Vault

# Interactive password prompt
ansible-playbook site.yml --ask-vault-pass

# Password from file (CI/CD)
ansible-playbook site.yml --vault-password-file ~/.vault_pass

# Password from environment variable
export ANSIBLE_VAULT_PASSWORD_FILE=~/.vault_pass
ansible-playbook site.yml

# Multiple vault IDs (different passwords for different environments)
ansible-playbook site.yml \
  --vault-id dev@~/.vault_dev \
  --vault-id prod@~/.vault_prod

Multiple Vault IDs

# Encrypt with a specific vault ID label
ansible-vault encrypt_string 's3cr3t' \
  --vault-id prod@~/.vault_prod \
  --name db_password

# The encrypted value is tagged with vault ID
db_password: !vault |
  $ANSIBLE_VAULT;1.2;AES256;prod
  ...

Vault Best Practices

group_vars/
├── all/
│   └── vars.yml           # plain variables
├── production/
│   ├── vars.yml           # plain — references vault_ prefixed vars
│   └── vault.yml          # encrypted — contains vault_ prefixed vars
└── staging/
    ├── vars.yml
    └── vault.yml

Convention: prefix all vault variables with vault_, reference them from plain vars files. This way you can view vars.yml without decrypting anything.


AWX / Ansible Tower

AWX is the open-source version of Red Hat Ansible Automation Platform (AAP/Tower). It provides a web UI, REST API, RBAC, schedules, and centralized credentials for Ansible.

graph TD
    subgraph AWX
        UI["Web UI / REST API"]
        JobTemplates[Job Templates]
        Inventory[Managed Inventory]
        Credentials[Credential Store]
        Projects[Projects - Git repos]
        Scheduler[Scheduler]
        CallbackURL["Webhook / Provisioning Callback"]
    end

    UI --> JobTemplates
    JobTemplates --> Inventory
    JobTemplates --> Credentials
    JobTemplates --> Projects
    Scheduler -->|triggers| JobTemplates
    CallbackURL -->|triggers| JobTemplates

    subgraph Execution
        EE[Execution Environment - container]
        ManagedHosts[Managed Hosts]
    end

    JobTemplates --> EE
    EE -->|SSH/SSM/WinRM| ManagedHosts

    subgraph Sources
        Git[Git repo - playbooks]
        Vault["HashiCorp Vault / AWS SM"]
        LDAP["LDAP / SSO"]
    end

    Projects --> Git
    Credentials --> Vault
    UI --> LDAP

Key AWX Concepts

Concept Description
Organization Top-level tenant. Users, teams, inventories scoped per org
Project A Git repository containing playbooks
Inventory Static or dynamic inventory (synced from cloud/LDAP)
Credential SSH key, AWS IAM, Vault token — stored encrypted
Job Template Playbook + inventory + credential + extra_vars
Workflow DAG of job templates with conditional branches
Execution Environment OCI container image with Ansible + dependencies
Notification Slack/email/webhook on job success/failure

AWX via kubectl (K8s install)

# Install AWX Operator
kubectl apply -k github.com/ansible/awx-operator/config/default?ref=2.x.x

# Create AWX instance
cat <<EOF | kubectl apply -f -
apiVersion: awx.ansible.com/v1beta1
kind: AWX
metadata:
  name: awx
spec:
  service_type: ClusterIP
  ingress_type: ingress
  hostname: awx.example.com
EOF

# Get admin password
kubectl get secret awx-admin-password -o jsonpath='{.data.password}' | base64 -d

Execution Environments (EE)

EEs replaced the old Python venv approach. A job always runs in a container with pinned Ansible + collections + Python deps.

# execution-environment.yml
version: 3
images:
  base_image:
    name: registry.redhat.io/ansible-automation-platform/ee-minimal-rhel8:latest

dependencies:
  galaxy:
    collections:
      - name: amazon.aws
        version: ">=7.0.0"
      - name: community.general
  python:
    - boto3>=1.28
    - botocore>=1.31
  system:
    - git [platform:rpm]

build_arg_defaults:
  EE_BASE_IMAGE: quay.io/ansible/awx-ee:latest
# Build EE image
pip install ansible-builder
ansible-builder build -t my-ee:1.0 -f execution-environment.yml
docker push my-registry/my-ee:1.0

Performance Tuning

forks — Parallel Connections

# ansible.cfg
[defaults]
forks = 50    # default is 5 — increase dramatically for large fleets
# Override at runtime
ansible-playbook site.yml -f 50

Pipelining

Without pipelining: for each task Ansible SCP-uploads the module, then runs it (2 SSH round trips).
With pipelining: module is sent via stdin in one round trip.

[ssh_connection]
pipelining = True    # ~3-5x faster for many tasks

Requires: requiretty must be disabled in /etc/sudoers (or use Defaults !requiretty).

SSH ControlMaster (Persistent Connections)

[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPath=/tmp/ansible-ssh-%h-%p-%r -o ControlPersist=60s

Reuses the SSH connection for all tasks on a host within 60 seconds instead of opening a new TCP connection per task.

Fact Caching

Gathering facts adds ~0.5-2s per host. Cache them to skip on subsequent runs.

[defaults]
gathering          = smart          # skip if cached, gather if not
fact_caching       = jsonfile
fact_caching_connection = /tmp/ansible_facts_cache
fact_caching_timeout = 86400        # 24 hours in seconds
# Redis cache (better for distributed teams)
fact_caching       = redis
fact_caching_connection = localhost:6379:0
fact_caching_timeout = 86400

Async Tasks

Run slow tasks in the background, poll for completion:

- name: Long-running backup (don't block)
  ansible.builtin.command: /usr/local/bin/backup.sh
  async: 3600          # max time to wait (seconds)
  poll: 0              # 0 = fire and forget, check later
  register: backup_job

# ... other tasks run here in parallel ...

- name: Wait for backup to complete
  ansible.builtin.async_status:
    jid: "{{ backup_job.ansible_job_id }}"
  register: backup_result
  until: backup_result.finished
  retries: 60
  delay: 30

Strategy Plugins

- hosts: web
  strategy: free          # each host runs independently, don't wait for others
  # strategy: linear     # default — all hosts complete task N before task N+1
  # strategy: host_pinned # like free but respects serial

Rolling Updates with serial

- hosts: web
  serial: "25%"         # update 25% of fleet at a time
  # serial: 2           # 2 hosts at a time
  # serial: [1, 5, 10]  # canary: 1, then 5, then 10 at a time
  max_fail_percentage: 10

Testing Ansible

Molecule — Unit/Integration Testing for Roles

pip install molecule molecule-docker
cd roles/nginx
molecule init scenario --driver-name docker
# molecule/default/molecule.yml
driver:
  name: docker
platforms:
  - name: ubuntu22
    image: geerlingguy/docker-ubuntu2204-ansible
    pre_build_image: true
  - name: centos9
    image: geerlingguy/docker-centos9-ansible
    pre_build_image: true

provisioner:
  name: ansible
  inventory:
    host_vars:
      ubuntu22:
        nginx_port: 80
      centos9:
        nginx_port: 8080

verifier:
  name: ansible
# molecule/default/converge.yml
---
- name: Converge
  hosts: all
  become: true
  roles:
    - role: nginx
# molecule/default/verify.yml
---
- name: Verify
  hosts: all
  tasks:
    - name: Check nginx is running
      ansible.builtin.service_facts:

    - name: Assert nginx is active
      ansible.builtin.assert:
        that:
          - ansible_facts.services['nginx.service'].state == 'running'
          - ansible_facts.services['nginx.service'].status == 'enabled'

    - name: Check nginx responds
      ansible.builtin.uri:
        url: "http://localhost:{{ nginx_port }}/"
        status_code: 200
# Full test cycle
molecule test            # create → converge → verify → destroy
molecule converge        # only provision (for iterative dev)
molecule verify          # only run verifier
molecule login           # SSH into test container
molecule destroy         # tear down

ansible-lint

pip install ansible-lint

# Lint playbook
ansible-lint site.yml

# Lint all YAML in project
ansible-lint

# With config
cat .ansible-lint
warn_list:
  - no-changed-when
  - command-instead-of-shell
skip_list:
  - yaml[line-length]

--check and --diff

# Dry run — show what would change, don't apply
ansible-playbook site.yml --check

# Show diffs for file changes
ansible-playbook site.yml --check --diff

# Both together (most useful for reviewing before apply)
ansible-playbook site.yml --check --diff -v

Directory Layout — Production Project

project/
├── ansible.cfg
├── requirements.yml             # collections + roles
├── site.yml                     # master playbook (imports others)
├── webservers.yml               # play for web tier
├── dbservers.yml                # play for db tier
│
├── inventories/
│   ├── production/
│   │   ├── hosts.yml            # static inventory or dynamic plugin config
│   │   ├── group_vars/
│   │   │   ├── all/
│   │   │   │   ├── vars.yml
│   │   │   │   └── vault.yml    # encrypted
│   │   │   └── web/
│   │   │       └── vars.yml
│   │   └── host_vars/
│   │       └── web01.example.com.yml
│   └── staging/
│       └── ...
│
├── roles/
│   ├── common/
│   ├── nginx/
│   └── myapp/
│
├── collections/                 # vendored collections (for air-gapped)
│   └── ansible_collections/
│
└── molecule/                    # integration tests (or per-role)

Debugging Playbooks

# Increase verbosity
ansible-playbook site.yml -v      # task results
ansible-playbook site.yml -vv     # file diffs + module args
ansible-playbook site.yml -vvv    # connection details
ansible-playbook site.yml -vvvv   # SSH debug output

# Run specific tasks by tag
ansible-playbook site.yml --tags "nginx,ssl"
ansible-playbook site.yml --skip-tags "slow_task"

# Run from a specific task
ansible-playbook site.yml --start-at-task "Deploy app config"

# Step through interactively
ansible-playbook site.yml --step

# Debug module — print variables
- ansible.builtin.debug:
    var: my_variable

- ansible.builtin.debug:
    msg: "Value is {{ my_variable }}, type is {{ my_variable | type_debug }}"

# Pause and inspect
- ansible.builtin.pause:
    prompt: "Check the state, press Enter to continue"

# Print all variables for a host
- ansible.builtin.debug:
    var: hostvars[inventory_hostname]

Common Patterns

Conditional Role Include

roles:
  - role: monitoring
    when: monitoring_enabled | default(true) | bool

Environment-Specific Variables

# Override for specific environment
ansible-playbook site.yml \
  -i inventories/production/ \
  -e "@overrides/production.yml" \
  --vault-password-file ~/.vault_prod

Idempotency Guard — Run Once

- name: Initialize database schema
  ansible.builtin.command: /opt/app/bin/db-migrate.sh
  args:
    creates: /var/lib/app/.db_initialized   # file acts as lock
  run_once: true
  delegate_to: "{{ groups['db'][0] }}"

Wait for Service to be Ready

- name: Wait for app port to open
  ansible.builtin.wait_for:
    host: "{{ ansible_host }}"
    port: 8080
    delay: 5
    timeout: 120
    state: started

Windows Support (WinRM)

Ansible manages Windows hosts via WinRM (Windows Remote Management), not SSH.

sequenceDiagram
    participant C as Control Node Linux/Mac
    participant W as Windows Host

    C->>W: HTTP/HTTPS to port 5985/5986
    Note over W: WinRM listener running
    W->>W: Authenticate via Kerberos/NTLM/Basic
    C->>W: Send PowerShell commands
    W-->>C: Return JSON result

Setup on Windows host (run as Administrator)

# Enable WinRM and configure for Ansible
$url = "https://raw.githubusercontent.com/ansible/ansible/devel/examples/scripts/ConfigureRemotingForAnsible.ps1"
$file = "$env:temp\ConfigureRemotingForAnsible.ps1"
(New-Object -TypeName System.Net.WebClient).DownloadFile($url, $file)
powershell.exe -ExecutionPolicy ByPass -File $file

Inventory for Windows

[windows]
win-server-01.example.com
win-server-02.example.com

[windows:vars]
ansible_user=Administrator
ansible_password="{{ vault_win_password }}"
ansible_connection=winrm
ansible_winrm_transport=ntlm         # or kerberos, credssp, basic
ansible_winrm_server_cert_validation=ignore
ansible_port=5986

Windows modules

# Install Windows feature
- ansible.windows.win_feature:
    name: Web-Server                  # IIS
    state: present
    include_management_tools: true

# Manage Windows service
- ansible.windows.win_service:
    name: W3SVC
    state: started
    start_mode: auto

# Copy file
- ansible.windows.win_copy:
    src: files/app.exe
    dest: C:\Apps\app.exe

# Run command
- ansible.windows.win_command:
    cmd: C:\Apps\setup.exe /quiet

# PowerShell script
- ansible.windows.win_shell: |
    Get-Service | Where-Object {$_.Status -eq 'Stopped'}

# Registry
- ansible.windows.win_regedit:
    path: HKLM:\SOFTWARE\MyApp
    name: Version
    data: "2.0"
    type: string

# Chocolatey package manager
- chocolatey.chocolatey.win_chocolatey:
    name: googlechrome
    state: present
pip install pywinrm           # required on control node
ansible-galaxy collection install ansible.windows
ansible-galaxy collection install chocolatey.chocolatey

Lookup Plugins

Lookups pull data from external sources into variables at playbook parse time (on the control node, not the remote).

# Read a file
vars:
  ssh_pub_key: "{{ lookup('file', '~/.ssh/id_rsa.pub') }}"

# Environment variable
  home_dir: "{{ lookup('env', 'HOME') }}"

# AWS SSM Parameter Store
  db_pass: "{{ lookup('amazon.aws.aws_ssm', '/prod/db/password', region='us-east-1') }}"

# HashiCorp Vault
  secret: "{{ lookup('community.hashi_vault.hashi_vault', 'secret/data/myapp token=s.xxxx') }}"

# Read lines from a file as a list
  server_list: "{{ lookup('file', 'servers.txt').splitlines() }}"

# URL (fetch remote content)
  latest_version: "{{ lookup('url', 'https://api.github.com/repos/org/repo/releases/latest') | from_json | json_query('tag_name') }}"

# Pipe (run command on control node)
  git_hash: "{{ lookup('pipe', 'git rev-parse --short HEAD') }}"

# CSV file
  # data/users.csv: name,uid,group
  user_info: "{{ lookup('csvfile', 'alice file=data/users.csv delimiter=, col=1') }}"

query() vs lookup()

lookup() returns a comma-joined string for multi-value results.
query() always returns a list — preferred with loop:

- name: Loop over all matching files
  ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "{{ query('fileglob', '/etc/ssl/certs/*.crt') }}"

Advanced Filters and Tests

selectattr / rejectattr — filter list of dicts

vars:
  users:
    - { name: alice, active: true,  role: admin }
    - { name: bob,   active: false, role: user }
    - { name: carol, active: true,  role: user }

tasks:
  - name: Only active users
    debug:
      var: users | selectattr('active') | list
    # → [alice, carol]

  - name: Active admins only
    debug:
      var: users | selectattr('active') | selectattr('role', 'equalto', 'admin') | list
    # → [alice]

  - name: Inactive users
    debug:
      var: users | rejectattr('active') | list
    # → [bob]

map — extract attribute from list

  - name: List of usernames only
    debug:
      var: users | map(attribute='name') | list
    # → ['alice', 'bob', 'carol']

  - name: List of names uppercased
    debug:
      var: users | map(attribute='name') | map('upper') | list

Tests — use with is

when: my_var is defined
when: my_var is not defined
when: my_var is none
when: my_path is file
when: my_path is directory
when: my_string is match('^web.*')     # regex match from start
when: my_string is search('prod')      # regex search anywhere
when: my_var is number
when: my_list is iterable
when: 'admin' in my_roles_list

Other useful filters

# Combine dicts (right takes precedence)
"{{ defaults | combine(overrides, recursive=True) }}"

# Extract keys from list of dicts
"{{ items | map(attribute='id') | list }}"

# Flatten nested list
"{{ [[1,2],[3,[4,5]]] | flatten }}"
"{{ [[1,2],[3,[4,5]]] | flatten(levels=1) }}"

# Zip two lists
"{{ ['a','b'] | zip(['x','y']) | list }}"  # → [['a','x'],['b','y']]

# Product (cartesian)
"{{ regions | product(instance_types) | list }}"

# Unique
"{{ my_list | unique | sort }}"

# Random item from list
"{{ my_list | random }}"

# Format strings
"{{ '%s-%s' | format(env, region) }}"

# to_yaml / to_json — for debug or writing config files
"{{ my_dict | to_nice_yaml }}"
"{{ my_dict | to_nice_json }}"

# from_yaml / from_json — parse strings
"{{ raw_yaml_string | from_yaml }}"

Custom Modules

When no built-in module fits, write your own in Python. Modules run on the managed node.

Minimal module structure

# library/my_module.py
from ansible.module_utils.basic import AnsibleModule

def run_module():
    argument_spec = dict(
        name=dict(type='str', required=True),
        state=dict(type='str', default='present', choices=['present', 'absent']),
        value=dict(type='int', required=False, default=0),
    )

    module = AnsibleModule(
        argument_spec=argument_spec,
        supports_check_mode=True,       # implement dry-run
    )

    name  = module.params['name']
    state = module.params['state']

    # Check current state
    current = get_current_state(name)   # your logic here
    changed = False

    if module.check_mode:
        module.exit_json(changed=(current != state), msg="Would change state")

    if state == 'present' and current != 'present':
        do_create(name, module.params['value'])
        changed = True
    elif state == 'absent' and current == 'present':
        do_delete(name)
        changed = True

    module.exit_json(
        changed=changed,
        name=name,
        state=state,
        msg=f"Resource {name} is {state}",
    )

def main():
    run_module()

if __name__ == '__main__':
    main()

Using the custom module

project/
├── library/
│   └── my_module.py      # auto-discovered by Ansible
└── playbook.yml
- name: Use custom module
  my_module:
    name: "myresource"
    state: present
    value: 42
  register: result

Module return values

module.exit_json(changed=False, msg="Already present", data={...})
module.fail_json(msg="Something went wrong", rc=1)

exit_json → task succeeds. fail_json → task fails, play stops (unless ignore_errors: true).


Tags — Deep Dive

Tags let you run or skip subsets of a playbook without changing the file.

- name: Install packages
  ansible.builtin.apt:
    name: nginx
  tags: [install, nginx]

- name: Configure nginx
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  tags: [configure, nginx]

- name: Always run this
  ansible.builtin.debug:
    msg: "This runs even with --tags"
  tags: always                      # 'always' is a special tag — runs regardless

- name: Never run by default
  ansible.builtin.command: /usr/local/bin/dangerous.sh
  tags: never                       # only runs if explicitly --tags never
# Run only nginx-tagged tasks
ansible-playbook site.yml --tags nginx

# Run install and configure
ansible-playbook site.yml --tags "install,configure"

# Skip slow tasks
ansible-playbook site.yml --skip-tags slow

# List all tags in a playbook
ansible-playbook site.yml --list-tags

# List tasks that would run
ansible-playbook site.yml --tags nginx --list-tasks

Tag inheritance

Tags cascade: role tags apply to all tasks in the role, play tags apply to all tasks in the play.

- hosts: web
  tags: web_tier             # all tasks in play inherit this tag
  roles:
    - role: nginx
      tags: nginx            # all tasks in role also get 'nginx' tag

Delegation and local_action

Run a task on a different host than the play target. Classic use: register a backend with a load balancer, or run a DB migration once from a specific host.

# delegate_to — run task on this host instead of current target
- name: Remove from load balancer before deploy
  ansible.builtin.uri:
    url: "http://lb.internal/api/deregister/{{ inventory_hostname }}"
    method: POST
  delegate_to: lb.internal          # runs on lb.internal, not the web server

# delegate_to localhost — run on control node
- name: Create DNS record in Route53
  amazon.aws.route53:
    zone: example.com
    record: "{{ inventory_hostname }}.example.com"
    type: A
    value: "{{ ansible_host }}"
  delegate_to: localhost
  run_once: true

# local_action — shorthand for delegate_to: localhost
- name: Write deployment log locally
  local_action:
    module: ansible.builtin.lineinfile
    path: /var/log/deployments.log
    line: "{{ inventory_hostname }} deployed at {{ ansible_date_time.iso8601 }}"

delegate_facts

- name: Gather facts from DB server, store on web server's vars
  ansible.builtin.setup:
  delegate_to: db01.internal
  delegate_facts: true           # facts stored under hostvars['db01.internal']

CI/CD Integration

GitHub Actions

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install Ansible
        run: pip install ansible boto3

      - name: Install collections
        run: ansible-galaxy collection install -r requirements.yml

      - name: Write SSH key
        run: |
          mkdir -p ~/.ssh
          echo "${{ secrets.DEPLOY_KEY }}" > ~/.ssh/deploy.pem
          chmod 600 ~/.ssh/deploy.pem

      - name: Write Vault password
        run: echo "${{ secrets.VAULT_PASS }}" > ~/.vault_pass

      - name: Deploy
        env:
          AWS_ACCESS_KEY_ID:     ${{ secrets.AWS_ACCESS_KEY_ID }}
          AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
        run: |
          ansible-playbook site.yml \
            -i inventory_aws_ec2.yml \
            --vault-password-file ~/.vault_pass \
            -e "env=production"

Jenkins Pipeline

// Jenkinsfile
pipeline {
    agent { label 'ansible' }

    environment {
        VAULT_PASS = credentials('ansible-vault-pass')
        AWS_CREDS  = credentials('aws-prod')
    }

    stages {
        stage('Lint') {
            steps {
                sh 'ansible-lint site.yml'
            }
        }
        stage('Check') {
            steps {
                withCredentials([[$class: 'AmazonWebServicesCredentialsBinding', credentialsId: 'aws-prod']]) {
                    sh '''
                        echo "${VAULT_PASS}" > /tmp/.vault_pass
                        ansible-playbook site.yml \
                          -i inventory_aws_ec2.yml \
                          --vault-password-file /tmp/.vault_pass \
                          --check --diff
                    '''
                }
            }
        }
        stage('Deploy') {
            when { branch 'main' }
            steps {
                sh 'ansible-playbook site.yml -i inventory_aws_ec2.yml --vault-password-file /tmp/.vault_pass'
            }
        }
    }
    post {
        always { sh 'rm -f /tmp/.vault_pass' }
    }
}

Running in a container (Execution Environment)

# Dockerfile for Ansible runner
FROM python:3.11-slim
RUN pip install ansible boto3 botocore pywinrm \
 && ansible-galaxy collection install amazon.aws google.cloud community.general
WORKDIR /ansible
ENTRYPOINT ["ansible-playbook"]
# GitLab CI
deploy:
  image: my-registry/ansible-runner:latest
  script:
    - ansible-playbook site.yml -i inventory_aws_ec2.yml
  variables:
    ANSIBLE_VAULT_PASSWORD_FILE: /tmp/vault_pass

Callback Plugins

Callback plugins hook into Ansible events (task start, result, play end) for custom output or notifications.

Built-in callbacks

# ansible.cfg
[defaults]
stdout_callback    = yaml          # prettier output (yaml|json|debug|dense|minimal)
callback_whitelist = timer,profile_tasks,mail
Callback What it does
timer Print total playbook runtime at end
profile_tasks Show time each task took
profile_roles Show time each role took
mail Email on failure
slack Post to Slack on play end
json Machine-readable JSON output (for CI parsing)
log_plays Log all play output to /var/log/ansible.log

Slack callback

# ansible.cfg
[defaults]
callback_whitelist = community.general.slack

[callback_slack]
webhook_url = https://hooks.slack.com/services/T.../B.../xxx
channel     = #deployments
username    = ansible-bot

Custom callback plugin

# callback_plugins/my_notify.py
from ansible.plugins.callback import CallbackBase

class CallbackModule(CallbackBase):
    CALLBACK_VERSION = 2.0
    CALLBACK_TYPE    = 'notification'
    CALLBACK_NAME    = 'my_notify'

    def v2_playbook_on_stats(self, stats):
        hosts = sorted(stats.processed.keys())
        for h in hosts:
            s = stats.summarize(h)
            if s['failures'] or s['unreachable']:
                self._post_alert(f"FAILED: {h} — {s}")

    def _post_alert(self, msg):
        import requests
        requests.post(os.environ['ALERT_WEBHOOK'], json={'text': msg})

failed_when and changed_when

Control when Ansible considers a task failed or changed — essential for command/shell tasks which are always changed.

# command module doesn't know if something changed — mark it explicitly
- name: Check if service config is valid
  ansible.builtin.command: nginx -t
  register: nginx_check
  changed_when: false                         # never marks as changed (read-only)
  failed_when: nginx_check.rc != 0

# Custom failure condition
- name: Run database migration
  ansible.builtin.command: /opt/app/bin/migrate
  register: migrate_result
  changed_when: "'0 migrations applied' not in migrate_result.stdout"
  failed_when:
    - migrate_result.rc != 0
    - "'already up to date' not in migrate_result.stdout"   # not a failure

# Shell with pipe — rc is always 0 from grep when match found
- name: Check process count
  ansible.builtin.shell: ps aux | grep nginx | grep -v grep | wc -l
  register: nginx_procs
  changed_when: false
  failed_when: nginx_procs.stdout | int < 2