In a nutshell
Imagine you run a global airline from a single control tower. Some aircraft are yours (on-prem servers in your own datacenters), some are leased from three different partners (AWS, Azure, GCP), and some are new drones (Kubernetes). You never fly a plane yourself. You issue one coordinated plan — “board, taxi, take off, in this order; if a flight aborts, hold its connections and bring it back to the gate” — and every aircraft, whoever built it, obeys the same plan. Ansible is that control tower for infrastructure change.
Hybrid and multi-cloud orchestration means driving change across environments that were never designed to cooperate — a bank’s RHEL fleet on vSphere, an AWS account, an Azure tenant, a GCP project, a Kubernetes cluster, and the network gear stitching them together — from one workflow, in the right order, with a dry-run you can approve and a rollback when something breaks. Ansible reaches each environment through its own dynamic inventory plugin (amazon.aws.aws_ec2, azure.azcollection.azure_rm, google.cloud.gcp_compute, kubernetes.core.k8s), pulls the correct credential per environment, and runs the same idempotent tasks everywhere.
Hold on to one mental split throughout this lesson: Terraform (or another IaC tool) provisions the raw infrastructure; Ansible configures and orchestrates the change across it. That division shows up everywhere here. A beginner starts with a single playbook that pings one VM in each cloud; an expert composes those plays into an Ansible Automation Platform (AAP) workflow that safely upgrades a payment processor across 12 datacenters and 3 clouds. Same ideas, different blast radius.
Level: Advanced (Tier 4 capstone) · Time: ~55 min · You’ll need: comfort with playbooks and inventory, and at least one cloud collection (AWS, Azure, or GCP).
A bank with 80 years of history runs a portfolio you can’t restart for fun: COBOL on z/OS handles ledger updates, RHEL 9 on vSphere serves the trading floor, AWS handles the mobile app, Azure handles Office 365 integration, GCP handles the data-science team’s BigQuery, and Kubernetes handles the new microservices the platform team is building. Most of these can’t move; all of them need to change weekly, and every change has to be auditable. The problem is not picking a tool — it’s picking a coordination plane that can drive all of them.
That coordination plane, for a wide swath of enterprises, is Ansible Automation Platform plus a careful approach to multi-tier inventory, automation mesh, and workflow chaining. This lesson — the capstone of the Ansible expert tier — covers how to build orchestrated, multi-environment changes that stretch across vSphere clusters, public clouds, Kubernetes, network gear, and Windows fleets, all in a single workflow with proper dependency ordering, dry-run gating, partial-failure handling, and rollback. If you’ve made it through the previous nine expert lessons, this is where they fit together.
Learning Objectives
By the end you will be able to:
- Design cross-platform inventories that combine static (on-prem network, vSphere) and dynamic (AWS, Azure, GCP) sources into one logical fabric.
- Use Ansible Automation Platform (AAP) Workflow Templates to chain Job Templates with
on_success/on_failure/alwaysbranches. - Configure automation mesh: hop nodes, execution nodes, and control nodes across network boundaries (DMZ, restricted VLANs, partner VPCs).
- Map identity across environments: AD-joined Linux + Windows, AWS IAM Roles via OIDC, Azure Workload Identity, GCP WIF, K8s ServiceAccounts.
- Manage credentials across three tiers — environment variables, Ansible Vault, and short-lived Workload Identity Federation — and know when each is appropriate.
- Split responsibilities correctly between Terraform (provision) and Ansible (configure/orchestrate), and hand off state between them.
- Detect and remediate configuration drift across clouds using idempotent tasks and check-mode gates.
- Implement dry-run gating: a
--checkworkflow that posts a diff to Slack/PR before the actual change runs. - Build “blast-radius-aware” rollouts:
serial:percentages, canary regions, ringed deployments. - Handle partial failures: workflow
convergence_node,any_errors_fatal,max_fail_percentage, plus rollback Job Templates. - Log every cross-environment change to a single audit stream (Splunk, Elastic, ServiceNow CMDB).
- Choose between AAP, AWX, plain CLI Ansible + GitOps, or a hybrid for your environment.
Prerequisites
- All nine previous Tier 4 expert lessons (network, AWS, Azure, GCP, Windows, K8s, containers, databases, VMware).
- Tier 1–3 fluency, especially AAP basics and dynamic inventory.
- Read-only access to your real production inventory across all clouds you’ll orchestrate.
- Comfort with the per-cloud collections — the AWS
amazon.awslesson, the Azureazure.azcollectionlesson, and the GCPgoogle.cloudlesson — since this capstone assumes you already know each cloud’s modules and inventory plugin and focuses on weaving them together.
Mental Model: Hybrid Orchestration Done Right
1. One inventory, many sources
The right approach is not “one playbook per platform” with separate inventories. It is one logical inventory that aggregates multiple sources: a static YAML for on-prem network gear and vSphere, dynamic plugins for AWS/Azure/GCP, and a kubernetes.core inventory for K8s clusters. The aggregator file in inventory/ lists each source; AAP or ansible-inventory merges them into one host graph at runtime.
2. Automation mesh routes execution, not data
In a real enterprise, the control node sits in a management VPC. AWS prod is in a different account behind a private VPC. Azure prod is on-prem-routed via ExpressRoute. The on-prem network gear is on a management VLAN that the cloud control node can’t reach. Automation mesh is AAP’s solution: deploy execution nodes close to the targets (a small EC2 in the AWS account, a small VM in Azure, a small VM in the management VLAN), wire them to the control plane via hop nodes across firewalls, and let AAP route each Job to the right execution node based on inventory location.
3. Workflows chain Job Templates with branching logic
A Workflow Template is a DAG (directed acyclic graph) of Job Templates. Edges are conditional: on_success continues the success path, on_failure triggers cleanup, always runs regardless. A real change workflow looks like: pre-check → backup → drain → upgrade → validate → traffic-shift → confirm. Each of those is a Job Template, and the workflow stitches them with the right branching logic.
4. Identity is the hard problem in hybrid
The control node is RHEL 9. It needs to talk to: vSphere (SSO user), Windows (Kerberos, AD-joined), AWS (IRSA / OIDC), Azure (Workload Identity), GCP (Workload Identity Federation), Kubernetes (kubeconfig with short-lived tokens), network gear (TACACS+ / RADIUS / SSH key). Don’t try to unify these — instead, give AAP credentials per environment and let each Job Template pull only the credentials it needs.
5. Audit trail beats clever automation
The hardest part of multi-cloud Ansible isn’t getting it to work; it’s proving what changed, when, and by whose authority. AAP’s job-event stream is the spine of this audit story — every task on every host gets a structured record. Ship those events to Splunk/Elastic/ServiceNow, tag them with the change ticket ID (extra_vars: { change_id: CHG0001234 }), and the audit becomes searchable.
Designing a Cross-Platform Inventory
The aggregator pattern: one directory of inventory sources, each loaded by its own plugin, all merged into one inventory graph.
inventory/
├── 01-static.yml # on-prem network gear, on-prem VMs
├── 02-vsphere.yml # community.vmware.vmware_vm_inventory
├── 03-aws.aws_ec2.yml # amazon.aws.aws_ec2
├── 04-azure_rm.yml # azure.azcollection.azure_rm
├── 05-gcp_compute.yml # google.cloud.gcp_compute
├── 06-k8s.yml # kubernetes.core.k8s
└── group_vars/
├── all.yml
├── tag_environment_prod.yml
└── tag_role_database.yml
01-static.yml (the on-prem skeleton):
all:
children:
network_devices:
hosts:
leaf-01.dc1.corp.example.com:
leaf-02.dc1.corp.example.com:
spine-01.dc1.corp.example.com:
vars:
ansible_connection: ansible.netcommon.network_cli
ansible_network_os: cisco.nxos.nxos
environment: prod
location: dc1
03-aws.aws_ec2.yml:
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
- us-west-2
filters:
tag:Environment: prod
instance-state-name: running
keyed_groups:
- key: tags.Role
prefix: role
- key: tags.Environment
prefix: env
- key: placement.region
prefix: aws_region
hostnames:
- tag:Name
- private-ip-address
compose:
ansible_host: private_ip_address
04-azure_rm.yml:
plugin: azure.azcollection.azure_rm
auth_source: auto
include_vm_resource_groups:
- prod-rg-east
- prod-rg-west
keyed_groups:
- key: tags.role
prefix: role
- key: location
prefix: az_region
- prefix: env
key: tags.environment
hostnames:
- private_ipv4_addresses[0]
05-gcp_compute.yml:
plugin: google.cloud.gcp_compute
projects:
- corp-prod-1234
auth_kind: serviceaccount
service_account_file: /etc/ansible/sa-prod.json
filters:
- status = RUNNING
- labels.environment = prod
keyed_groups:
- key: labels.role
prefix: role
- key: zone
prefix: gcp_zone
hostnames:
- networkInterfaces[0].networkIP
Run ansible-inventory -i inventory/ --graph and you get one tree:
@all:
|--@aws_region_us_east_1:
| |--10.10.1.5
| |--10.10.1.6
|--@az_region_eastus:
| |--10.20.1.5
|--@gcp_zone_us_central1_a:
| |--10.30.1.5
|--@network_devices:
| |--leaf-01.dc1.corp.example.com
|--@role_app:
| |--10.10.1.5
| |--10.20.1.5
|--@role_db:
| |--10.10.1.6
| |--10.30.1.5
|--@env_prod:
| |--10.10.1.5
| |--10.10.1.6
| |--10.20.1.5
| |--10.30.1.5
role_app now contains app servers from AWS and Azure together; role_db spans AWS and GCP. A play that targets role_app runs across all of them — but each task is dispatched to the right execution node by automation mesh.
The trick that makes clouds merge: the same prefix: in keyed_groups across two different plugins. AWS tags its instances Role=app and Azure tags its VMs role=app; because both plugin files declare prefix: role, the resulting groups (role_app) are the same group, and hosts from both clouds pile into it. Choose a consistent tagging convention across clouds up front — it is the single decision that makes “target all prod app servers everywhere” a one-liner instead of a scripting project. (Cloud tag keys are case-sensitive and differ by provider, so normalize with the key: expression, not by hoping the tags already match.)
Automation Mesh: Routing Execution
Automation mesh has three node types:
- Control node: hosts the AAP UI, scheduler, and database.
- Execution node: actually runs the Job. Each Job’s payload (collections, EE image) is shipped here.
- Hop node: a relay that forwards traffic across network boundaries (e.g. across a DMZ).
A real layout for a bank’s prod workflow:
[Control] --(public)--> [Hop: DMZ-1] --(private)--> [Exec: AWS-prod]
|
+--(private)-----> [Exec: Azure-prod]
|
+--(MPLS)--> [Hop: DC1-mgmt] --(VLAN-100)--> [Exec: vSphere-prod]
\
+--(VLAN-200)--> [Exec: Network-mgmt]
Each execution node has the credentials and network reachability for its segment. The control node never directly touches the prod targets — it only schedules Jobs to the right execution nodes, which do the actual SSH/WinRM/HTTPS calls.
In AAP, configure an Instance Group per region/segment, then bind inventories to instance groups:
Inventory "AWS-prod-us-east-1" → instance_group "exec-aws-prod-us-east-1"
Inventory "Azure-prod-eastus" → instance_group "exec-azure-prod-eastus"
Inventory "vSphere-prod-dc1" → instance_group "exec-vsphere-prod-dc1"
When you launch a Job Template against Inventory "AWS-prod-us-east-1", AAP routes it through the mesh to the AWS execution node — no separate “did you remember to switch context?” step.
Workflow Templates: Chaining Job Templates
A Workflow Template is a DAG. Each node is a Job Template (or another workflow). Edges are conditional.
Example: production app deployment that touches AWS, Azure, and on-prem K8s:
[Pre-check]
|
+--on_success--> [Backup vSphere VMs]
|
+--on_success--> [Snapshot AWS RDS]
|
+--on_success--> [Drain LB traffic]
|
+--on_success--> [Rolling upgrade k8s]
|
+--on_success--> [Validate]
|
+--on_success--> [Restore traffic]
+--on_failure--> [Rollback k8s] --> [Restore traffic]
|
+--on_failure--> [Restore RDS] --> [Restore vSphere]
In AAP, this is a 9-node Workflow Template. Each node is a Job Template that already exists. Edges are configured in the Workflow visualizer.
Each Job Template can pass data to the next via set_stats:
- name: Record current image tag
ansible.builtin.set_stats:
data:
previous_image_tag: "{{ current_image_tag }}"
per_host: false
Subsequent Job Templates in the workflow can read previous_image_tag from extra_vars.
Dry-Run Gating: Diff → Approval → Apply
The single highest-leverage practice in multi-cloud Ansible: never apply without a diff. The pattern:
- Dry-run Job Template runs the same playbook with
--check --diff. - Output is rendered to a diff document and posted to a PR or Slack thread.
- A human approves.
- Apply Job Template runs the same playbook without
--check.
In AAP this is two Job Templates wired in a Workflow with an Approval Node between them:
[Dry-run] --on_success--> [Approval Node] --on_approved--> [Apply] --on_success--> [Validate]
--on_denied--> [Notify-denied]
The Approval Node pauses the workflow until a human clicks “Approve” in the AAP UI (or replies to a notification). This is your last-line audit gate before production change.
Identity Across Environments
The cleanest pattern: per-environment credential, no shared secrets.
| Target | AAP Credential Type | Backing identity |
|---|---|---|
| On-prem Linux | Machine | SSH key, AD-joined service account |
| Windows | Machine (winrm/kerberos) | gMSA in AD |
| Network gear | Network | TACACS+ user |
| AWS | Amazon Web Services | IAM Role assumed via OIDC from AAP’s K8s SA |
| Azure | Microsoft Azure Resource Manager | Workload Identity from AAP’s K8s SA |
| GCP | Google Compute Engine | WIF mapped from AAP’s K8s SA |
| vSphere | VMware vCenter | SSO user with custom RBAC |
| Kubernetes | OpenShift / Kubernetes Bearer Token | Short-lived OIDC token |
Notice the pattern: AAP runs in a K8s cluster (or OpenShift) — its workload identity (the K8s ServiceAccount of the Execution Environment pod) is the root of trust, and each cloud is configured to trust that identity via OIDC. No long-lived keys anywhere.
Credential Management: Environment, Vault, and Workload Identity
The identity table above says which credential each target uses. This section is about where the secret lives and how it reaches the module at runtime — the part beginners get wrong most often. There are three tiers, and mature shops climb them left-to-right:
| Tier | Where the secret lives | Rotation | Use when |
|---|---|---|---|
| 1 — Environment / config | Env vars, ~/.aws/credentials, a SA JSON file on disk |
Manual | Your laptop, a demo, a throwaway lab |
| 2 — Ansible Vault | Encrypted vars file in git, decrypted at runtime with a vault password | Manual, but auditable in git | Static secrets that rarely change (API tokens, a bootstrap password) |
| 3 — Workload Identity Federation (WIF/IRSA) | Nowhere persistent — a short-lived token minted per job from a trusted workload identity | Automatic (minutes-long TTL) | Production. The target state you want to reach |
Tier 1 — environment. The cloud collections lean on each provider SDK’s native credential chain, so you often set nothing in the playbook:
amazon.aws.*uses boto3’s chain:AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY/AWS_SESSION_TOKENenv vars,AWS_PROFILE, or the instance/pod role.azure.azcollection.*withauth_source: autoreadsAZURE_CLIENT_ID/AZURE_SECRET/AZURE_TENANT, a managed identity, or a federated token file.google.cloud.*reads a service-account JSON (auth_kind: serviceaccount) or Application Default Credentials (auth_kind: application).
This is fine for a laptop and dangerous for prod — plaintext keys on disk are exactly the thing you don’t want on a Tier-0 automation host.
Tier 2 — Ansible Vault. For static secrets, encrypt them at rest in git and decrypt at runtime. The Ansible Vault lesson covers this in depth; the hybrid-relevant part is that a play never contains the plaintext — it references a variable whose value came from an encrypted file:
- name: Create the application bucket
amazon.aws.s3_bucket:
name: "{{ app_bucket_name }}"
state: present
tags:
environment: prod
managed_by: ansible
Here app_bucket_name (and any real secret) lives in group_vars/all/vault.yml, encrypted with ansible-vault. AAP stores the vault password itself as a Vault credential, so no human types it during a job. For rotating secrets — database passwords that change nightly — reach past static Vault to an external secrets store (HashiCorp Vault, AWS Secrets Manager) via a lookup at runtime, so the secret is never committed at all.
Tier 3 — Workload Identity Federation. The production target: no static key exists to leak. AAP runs its Execution Environment pods with a Kubernetes ServiceAccount; each cloud is configured to trust that ServiceAccount’s OIDC token and mint a short-lived credential in exchange:
- AWS (IRSA): the pod’s projected SA token is exchanged via
sts:AssumeRoleWithWebIdentityfor temporary AWS credentials — boto3 does this automatically, so the playbook sets nothing. - Azure (Workload Identity): a federated token file is exchanged for an Azure AD token;
auth_source: autopicks it up. - GCP (WIF): Application Default Credentials resolve the federated token to a GCP access token — so the inventory plugin needs no key file:
plugin: google.cloud.gcp_compute
projects:
- corp-prod-1234
auth_kind: application
filters:
- status = RUNNING
keyed_groups:
- key: labels.role
prefix: role
The whole point: the credential that can change your AWS account never sits on disk, never enters git, and expires in minutes. A leaked backup or a compromised git history yields nothing usable. This is why “give AAP a per-environment credential and let each job pull only what it needs” is not bureaucracy — it is the mechanism that keeps a single compromised playbook from becoming a cross-cloud breach.
Provision with Terraform, Configure with Ansible
Beginners often try to build entire cloud estates with Ansible cloud modules — VPCs, subnets, route tables, the lot. It works, but you’re fighting the tool: Ansible has no first-class state file and no dependency graph, so it can’t cheaply answer “what exists, what must change, in what order, and what must be destroyed.” That is exactly what Terraform (or OpenTofu/Pulumi) is built for. The industry-standard division:
| Concern | Terraform (provision) | Ansible (configure & orchestrate) |
|---|---|---|
| Create VPCs, subnets, load balancers, managed DBs | Yes — declarative, with state | Rarely (only day-0 glue) |
| Install/patch packages, render config, manage services | No | Yes |
| Multi-step change with human approval and rollback | No | Yes (workflows) |
| Cross-host ordering (drain → upgrade → validate) | No | Yes (serial, handlers, workflows) |
| Source of truth for “what infra exists” | Terraform state | — (reads it) |
| Source of truth for “how hosts are configured” | — | Ansible playbooks/roles |
Read it as Day-0 vs Day-1/Day-2: Terraform stands the infrastructure up (Day-0); Ansible configures it into service (Day-1) and operates it for the rest of its life — patching, drift correction, coordinated change (Day-2).
The handoff is the interesting part, and Ansible has a native path for it. The cloud.terraform collection lets Ansible both run Terraform and read its state as inventory:
- name: Provision base network with Terraform, then configure it
hosts: localhost
tasks:
- name: Apply the network stack
cloud.terraform.terraform:
project_path: ./terraform/network
state: present
force_init: true
register: tf
And build inventory directly from Terraform state, so the machines Terraform just created are immediately targetable — no tag-scraping race:
# inventory/07-tfstate.yml
plugin: cloud.terraform.terraform_state
backend_type: s3
backend_config:
bucket: corp-tfstate-prod
key: network/terraform.tfstate
region: us-east-1
In AAP the same idea becomes a Workflow: a Terraform Job Template (or a Terraform Cloud run) provisions, its outputs feed the inventory, and an Ansible Job Template configures. You get Terraform’s declarative provisioning and Ansible’s imperative, ordered, approvable orchestration — each doing what it’s best at.
Idempotency and Drift Across Clouds
Idempotency is the property that running the same playbook twice leaves the system in the same state, and the second run reports changed=0. It does not mean “the playbook never changes anything” — the first run may change plenty. It means the playbook describes a destination, and Ansible only acts when reality differs from that destination. This is what makes it safe to re-run the same play on a nightly schedule across thousands of hosts in three clouds.
Drift is the flip side: reality has diverged from the declared state — someone SSH’d in and hand-edited /etc/nginx/nginx.conf, or a cloud-console change bypassed automation. Because idempotent tasks report when they would change something, you can turn a normal playbook into a drift detector by running it in check mode and failing if anything would change:
- name: Detect configuration drift (read-only)
hosts: role_app
check_mode: true
tasks:
- name: Render nginx config against source of truth
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
register: cfg
- name: Fail if the live config has drifted
ansible.builtin.assert:
that: not cfg.changed
fail_msg: "Drift on {{ inventory_hostname }}: /etc/nginx/nginx.conf differs from source of truth"
success_msg: "No drift on {{ inventory_hostname }}"
Schedule that in AAP nightly against role_app (which, remember, spans AWS and Azure) and you get a single cross-cloud drift alarm. When it fires, the same playbook run without check_mode remediates the drift — detection and correction share one source of truth.
The honest caveat: not every task is naturally idempotent, and not every module supports check mode. ansible.builtin.command and shell run whatever you give them every time and always report changed. Make them honest:
- name: Generate the nightly report (be honest about change)
ansible.builtin.command: /usr/local/bin/gen-report.sh
register: report
changed_when: "'WROTE' in report.stdout"
check_mode: false
changed_when teaches Ansible what “changed” actually means for this command; check_mode: false says “this task can’t be dry-run, so run it for real even during --check” (use it only for genuinely read-only or safe commands). Cloud modules vary too — most amazon.aws/azure.azcollection/google.cloud resource modules support check mode, but a few report changes imperfectly because reading current cloud state is expensive. The rule: check the module’s documentation for check-mode support before you trust a dry-run diff from it. Drift detection is only as truthful as the least-idempotent task in the play.
Blast-Radius-Aware Rollouts
For changes that touch many hosts, use these patterns to limit the damage radius:
Pattern 1: serial: with percentages
- hosts: webservers
serial: "10%" # 10% of the fleet at a time
tasks:
- name: Apply config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: reload nginx
serial: "10%" rolls through the fleet 10% at a time. Combined with max_fail_percentage: 5 — if more than 5% of a batch fails, abort.
Pattern 2: Canary regions
- hosts: aws_region_us_east_1 # canary first
tasks: [ ... ]
- hosts: aws_region_us_west_2 # then west
tasks: [ ... ]
- hosts: az_region_eastus # then Azure east
tasks: [ ... ]
In AAP this becomes a Workflow with one Job Template per region, gated by validation checks between each.
Pattern 3: Ringed deployment
Tag hosts with rings: ring_0 (canary, 1%), ring_1 (early adopters, 10%), ring_2 (general, 89%). Deploy ring-by-ring with bake time between rings.
Rollback Strategy
Ansible isn’t transactional. You can’t BEGIN; ... ROLLBACK; an apt install + a route change. Rollback discipline:
- Snapshot stateful resources before change (vSphere snapshot, RDS snapshot, EBS snapshot, GCP disk snapshot).
- Capture pre-change state in
set_stats(image tag, replica count, config version). - Have a dedicated rollback Job Template for each change type. The rollback playbook reads the captured state and reverses the change.
- Test rollbacks regularly. A rollback path you’ve never run is theater.
The rollback Job Template is a peer to the apply Job Template, not an afterthought:
[Apply] --on_failure--> [Rollback] --on_success--> [Restore traffic] --> [Notify-failure]
Audit Trail
AAP emits Job Events as a structured stream. Each event has timestamp, host, task, status, and diff. Ship them to your audit pipeline:
# /etc/tower/conf.d/logging.py (AAP) — log streaming
LOGGING['handlers']['external_logger'] = {
'class': 'logging.handlers.SysLogHandler',
'address': ('splunk-hec.corp.example.com', 5140),
'formatter': 'json',
}
Tag every job with the change ticket ID:
# Workflow extra_vars
change_id: "{{ change_id }}"
approver: "{{ approver }}"
Now your audit pipeline has: who launched the workflow, when each task ran, what changed on which host, with which inputs — all queryable.
Hands-on Free Lab: Cross-Platform Inventory + Workflow
Free, runs against LocalStack (AWS), Azurite (Azure), kind (K8s), and a local Multipass VM (on-prem). The full lab is documented at github.com/example/ansible-hybrid-lab; here’s the skeleton:
mkdir -p ~/ansible-hybrid-lab && cd ~/ansible-hybrid-lab
# Build aggregator inventory
mkdir -p inventory
cat > inventory/01-static.yml <<'EOF'
all:
children:
onprem:
hosts:
local-vm:
ansible_host: 192.168.64.10
ansible_user: ubuntu
EOF
cat > inventory/02-aws.aws_ec2.yml <<'EOF'
plugin: amazon.aws.aws_ec2
endpoint: http://localhost:4566 # LocalStack
regions:
- us-east-1
filters:
instance-state-name: running
keyed_groups:
- key: placement.region
prefix: aws_region
EOF
cat > inventory/03-k8s.yml <<'EOF'
plugin: kubernetes.core.k8s
connections:
- kubeconfig: ~/.kube/config
context: kind-ansible-lab
EOF
# Build hybrid playbook
cat > site.yml <<'EOF'
---
- name: Phase 1 — On-prem hosts
hosts: onprem
tasks:
- name: Ensure nginx
ansible.builtin.apt:
name: nginx
state: present
become: true
- name: Phase 2 — AWS hosts (LocalStack)
hosts: aws_region_us_east_1
gather_facts: false
tasks:
- name: Ping
ansible.builtin.ping:
- name: Phase 3 — K8s namespace
hosts: localhost
gather_facts: false
tasks:
- name: Apply baseline
kubernetes.core.k8s:
context: kind-ansible-lab
state: present
definition:
apiVersion: v1
kind: Namespace
metadata:
name: hybrid-lab
EOF
# Run
ansible-inventory -i inventory --graph
ansible-playbook -i inventory site.yml
Three different platforms, one inventory, one playbook, three plays — that’s the hybrid pattern in miniature.
Going deeper
This is the section for the reader who already runs multi-cloud Ansible and wants the internals — the parts that decide whether your orchestration survives contact with a 10,000-host change.
Automation mesh under the hood: Receptor
Automation mesh isn’t magic — it’s the Receptor overlay network. Each control, hop, and execution node runs a receptor process; they connect (default TCP 27199) into a mesh and route work units to each other over mutually-authenticated TLS. When AAP launches a job bound to an instance group, it hands a signed work unit to the mesh; Receptor routes it hop-by-hop to the chosen execution node, where ansible-runner executes the play inside an Execution Environment container. Two consequences worth internalizing: (1) the port must be open bidirectionally between adjacent mesh nodes — mesh links are not one-way; (2) the control node never needs a route to your targets, only a route to the next mesh node, which is precisely why mesh solves the “control plane can’t reach the restricted VLAN” problem without VPN sprawl.
Execution Environments are your reproducibility boundary
An EE is an OCI image built by ansible-builder from an execution-environment.yml: a base image plus a requirements.yml (Galaxy collections), requirements.txt (pip), and bindep.txt (system packages). Because every job runs in a fresh EE container, nothing leaks between jobs — which is why set_stats exists as the sanctioned hand-off channel. The EE is also where you pin versions:
# requirements.yml — baked into the EE, this is your provenance
collections:
- name: amazon.aws
version: ">=7.0.0,<8.0.0"
- name: azure.azcollection
version: ">=2.3.0"
- name: google.cloud
version: ">=1.3.0"
- name: kubernetes.core
version: ">=3.0.0"
- name: cloud.terraform
version: ">=2.0.0"
A floating collection version is how “it worked yesterday, broke today, nobody touched anything” happens — a collection released a new minor and a module argument changed. Pin in the EE, and ansible-galaxy collection list in the build log becomes your audit of exactly what code ran.
How set_stats actually crosses the workflow boundary
set_stats writes into the job’s artifact data. AAP forwards those artifacts to the next Workflow node as extra_vars — but only when stats forwarding is enabled and, for a single scalar shared across the whole run, you set per_host: false (otherwise you get per-host values you didn’t expect). Because the value lands as extra_vars, it sits near the top of variable precedence — above group/host vars, below only the explicit command line and workflow-level extra_vars. That is exactly what you want for a rollback token: the captured previous_image_tag should override any default the rollback play might otherwise pick up.
Check mode is not uniform — treat it per module
--check is honest only to the degree each module implements it. File/template/package/service modules support check mode and --diff well. Many network resource modules (via ansible.netcommon) support both and show the config diff. But some cloud modules report “would change” imperfectly because reading current state is expensive, and command/shell can’t dry-run at all. Never present a --check diff as gospel across a mixed play — validate check-mode support for the specific modules on your critical path, and mark the un-checkable ones explicitly with check_mode: false + a correct changed_when.
Performance at hybrid scale
A 10,000-host cross-cloud job lives or dies on tuning:
- Forks: the default is 5. Raise
forks(50–100+) so Ansible addresses many hosts in parallel; the control/execution node’s CPU and memory are the ceiling. - SSH pipelining:
[ssh_connection] pipelining=true(orANSIBLE_PIPELINING=true) collapses multiple SSH round-trips per task into one — often a 2–4× wall-clock win on Linux fleets. - Fact caching: cache facts (redis/jsonfile) and use
gather_subset: minso you don’t re-gather everything on every run of a huge inventory. - Strategy:
strategy: freelets fast hosts race ahead instead of waiting at each task barrier — ideal when hosts are independent; keeplinearwhen ordering across hosts matters. - Async: wrap long-running tasks in
async/poll: 0so they don’t hold a fork. - Spread the load: put execution nodes near the targets via instance groups; latency to a us-east target from a us-east execution node beats round-tripping from a central control plane.
The dedicated performance-tuning lesson drills each of these; at hybrid scale they compound, because you’re multiplying per-task latency by hosts and by cross-region network distance.
The distributed-change reality: no atomic cross-cloud transaction
There is no two-phase commit across AWS + Azure + GCP. You cannot make “upgrade the app in all three clouds” atomic — a mid-flight failure will leave one cloud changed and another not. Accept it and design for partial failure instead of pretending it away: snapshot stateful resources before the change; make each step individually reversible; give each environment a compensating rollback (saga-style) rather than one global undo; and put a convergence node at the end that validates the whole system end-to-end before declaring success. Clever orchestration doesn’t beat this constraint — disciplined, reversible, snapshot-first design does.
Practice challenges
Work these in order — they climb from “merge two sources” to “design a convergent multi-cloud workflow.” Each has a solution with the command/manifest and a one-line why.
1. (Beginner) Aggregate two inventory sources into one graph. Create an inventory/ directory with a static on-prem host and an amazon.aws.aws_ec2 source, and prove they merge into a single tree.
<details> <summary>Solution</summary>
# inventory/01-static.yml
all:
children:
onprem:
hosts:
app-onprem-01.corp.example.com:
# inventory/02-aws.aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions: [us-east-1]
filters:
instance-state-name: running
Then: ansible-inventory -i inventory/ --graph
Why: pointing -i at the directory (not a file) tells Ansible to load every source in it and merge the results — the foundation of one-inventory-many-sources.
</details>
2. (Beginner→Intermediate) Land AWS and Azure app servers in one cross-cloud group. Tag AWS instances Role=app and Azure VMs role=app; make both appear in a single role_app group.
<details> <summary>Solution</summary>
# in 02-aws.aws_ec2.yml
keyed_groups:
- key: tags.Role
prefix: role
# in 03-azure_rm.yml
keyed_groups:
- key: tags.role
prefix: role
ansible-inventory -i inventory/ --graph now shows @role_app containing hosts from both clouds.
Why: the same prefix: role in both plugins produces the same group name, so hosts from different clouds merge into it — even though the cloud tag keys (Role vs role) differ, the key: expression normalizes them.
</details>
3. (Intermediate) Bound the blast radius of a fleet change. Write a play that updates webservers 20% at a time and aborts the whole run if more than 10% of any batch fails.
<details> <summary>Solution</summary>
- name: Roll config across the fleet, bounded blast radius
hosts: webservers
serial: "20%"
max_fail_percentage: 10
tasks:
- name: Apply nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: reload nginx
handlers:
- name: reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
Why: serial bounds how many hosts change at once; max_fail_percentage bounds how much failure you tolerate before stopping — together they cap the damage a bad change can do.
</details>
4. (Intermediate) Turn a normal play into a drift gate. Write a check-mode play that fails when /etc/nginx/nginx.conf on role_app differs from your template.
<details> <summary>Solution</summary>
- name: Nightly drift gate
hosts: role_app
check_mode: true
tasks:
- name: Render config against source of truth
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
register: cfg
- name: Fail on drift
ansible.builtin.assert:
that: not cfg.changed
fail_msg: "Drift on {{ inventory_hostname }}"
Why: in check mode the template task reports whether it would change the file without touching it; asserting not cfg.changed converts “would change” into a hard drift alarm you can schedule.
</details>
5. (Advanced) Hand a rollback token between two jobs. In an “apply” play, capture the current container image tag with set_stats; in a separate “rollback” play, read it back to redeploy the previous tag.
<details> <summary>Solution</summary>
# apply.yml — before deploying the new tag
- name: Capture the currently deployed image tag
ansible.builtin.set_stats:
data:
previous_image_tag: "{{ current_image_tag }}"
per_host: false
# rollback.yml — a peer Job Template in the workflow
- name: Redeploy the previous image
kubernetes.core.k8s:
state: present
definition: "{{ lookup('template', 'deploy.yaml.j2') }}"
vars:
image_tag: "{{ previous_image_tag }}" # arrives as extra_vars
Why: set_stats (with per_host: false for a single scalar) is the only sanctioned channel to pass a value from one workflow Job Template to the next — the rollback job receives it as extra_vars.
</details>
6. (Advanced) Design a convergent multi-cloud upgrade workflow. Produce a DAG that upgrades AWS, Azure, and GCP in parallel, gates each cloud on its own validation, converges to one end-to-end test, requires human approval before apply, and has a per-cloud rollback path.
<details> <summary>Solution</summary>
graph TD
A[Pre-check: all clouds healthy] --> B[Snapshot: RDS + Azure DB + GCP disks]
B --> APV{Approval Node}
APV -- approved --> C1[Upgrade AWS]
APV -- approved --> C2[Upgrade Azure]
APV -- approved --> C3[Upgrade GCP]
APV -- denied --> DN[Notify: denied]
C1 --> V1[Validate AWS]
C2 --> V2[Validate Azure]
C3 --> V3[Validate GCP]
C1 -. on_failure .-> R1[Rollback AWS]
C2 -. on_failure .-> R2[Rollback Azure]
C3 -. on_failure .-> R3[Rollback GCP]
V1 --> CV[Convergence: end-to-end test]
V2 --> CV
V3 --> CV
CV --> DONE[Restore traffic + notify success]Why: parallel per-cloud paths give each cloud an independent blast radius and its own rollback; the convergence node fires only after all validations pass, so “success” means the whole system works — not just that three jobs exited 0. </details>
Common beginner mistakes
These are conceptual traps — wrong mental models, not just wrong syntax. Each is misconception → why it’s wrong → the right model.
1. “Ansible replaces Terraform for multi-cloud.” Ansible cloud modules can create resources, but Ansible holds no declarative state and no dependency graph, so it can’t cheaply reconcile “what exists vs. what should” or destroy cleanly. Right model: Terraform provisions (Day-0, stateful); Ansible configures and orchestrates (Day-1/2). Hand off via cloud.terraform.terraform_state — use each tool for its strength.
2. “One playbook and one inventory per cloud is cleaner.” It feels tidy but you lose cross-cloud groups, duplicate logic three ways, and can never target “all prod app servers everywhere” in a single run. Right model: one aggregator inventory, many source plugins, shared keyed_groups — a single role_app spans clouds.
3. “check mode means it’s safe to run anywhere.” Not every module supports --check; command/shell and some cloud modules either error or do nothing useful in dry-run, so a clean --check can hide real risk. Right model: verify check-mode support per module, set check_mode: false + a correct changed_when on tasks that can’t dry-run, and never assume --check proved a play harmless.
4. “Idempotent means the playbook won’t change anything.” Idempotent means it converges to the declared state — the first run may change a great deal; only subsequent runs are no-ops once reality already matches. Right model: idempotency is about the destination, not about doing nothing.
5. “I’ll make one super-credential that can reach every cloud.” A single identity that can change AWS and Azure and GCP is a catastrophic blast radius and one point of compromise. Right model: per-environment, short-lived credentials (OIDC/IRSA/WIF); each job pulls only what it needs.
6. “set_stats will obviously carry my variable to the next job.” Stats forwarding must be enabled, and a single scalar needs per_host: false; otherwise the next Job Template never sees the value. Right model: treat set_stats as an explicit, configured channel and confirm the value arrives as extra_vars.
7. “Rollback just means re-run the old playbook with the old variables.” Ansible isn’t transactional — an apt upgrade + a schema migration + a route change do not reverse by replaying old vars. Right model: snapshot stateful resources first, capture pre-change state, and write a dedicated, tested rollback path per change type.
8. “We need AAP before we can do any of this.” Aggregated inventory, playbooks, serial rollouts, and check-mode gates all work with plain ansible-playbook and a CI runner. Right model: start with core Ansible; adopt AAP when multi-team RBAC, automation mesh across isolated networks, and central audit become the actual bottleneck.
Common Mistakes & Troubleshooting
1. Cross-platform inventory mixes hostnames in unexpected ways
AWS uses private_ip_address while vSphere uses VM name. When merged, the same host appears under multiple identifiers. Use compose: ansible_host: ... in each plugin to normalize, and unique hostnames: per source.
2. Automation mesh hop node can’t reach execution node Mesh uses port 27199 by default. Firewall it open between hop and execution, in both directions. Mesh is bidirectional.
3. Workflow Approval Node times out
Default approval timeout is 1 hour. Set timeout: per node, or configure a default in AAP settings. Plan workflows for human-time, not machine-time.
4. set_stats data isn’t available in the next Job Template
set_stats only propagates if --stats is set in the Job Template’s verbosity, or if the workflow is configured to forward stats. Check Job Template settings.
5. Multi-region rollout — one region’s failure shouldn’t block others
Use parallel paths in the Workflow Template instead of serial. Each region is an independent path; a failure in us-east-1 doesn’t block eu-west-1. Use a convergence node at the end to aggregate.
6. Identity rotation breaks AAP credentials You rotated the OIDC trust relationship in AWS but AAP still has cached creds. AAP re-fetches per-job, so the next job will use new creds — but in-flight jobs fail. Plan rotations for low-traffic windows, or pre-warm credentials.
7. Audit pipeline floods Splunk with low-value events
AAP emits an event per task per host. A 10,000-host job is 10,000+ events. Filter at AAP (only failed events go to Splunk), or use Splunk index sizing carefully.
Best Practices
- Always have a Pre-check Job Template that validates assumptions (cluster healthy, datastore has space, target hosts reachable) before the change starts.
- Use parallel paths in workflows for independent regions/clouds. Serializing them adds runtime without safety benefit.
- Tag every change with the ticket ID. Searchable audit trail beats free-text descriptions.
- Test rollback paths quarterly. Schedule a “game day” where you intentionally fail an apply and verify rollback works.
- Keep credentials per-environment, not per-team. A shared “Ansible-prod” credential is a single point of compromise.
- Use Approval Nodes for prod changes. The 30-second pause for human approval is cheap insurance.
- Measure workflow duration over time. Workflows that grow from 30 minutes to 4 hours are a smell — identify and parallelize the slow path.
- Don’t put change windows in Ansible code. Use AAP’s schedule feature for timing; the playbook should be idempotent regardless of when it runs.
Security Notes
- Compromise of AAP = compromise of everything. Treat AAP infrastructure as Tier 0. RBAC, MFA, network isolation, audit logging — every prod-grade SRE practice.
- Use ephemeral credentials (OIDC, IRSA, WIF, Workload Identity) over long-lived keys. AAP’s K8s ServiceAccount is the root of trust; clouds federate from it.
- Encrypt secrets in transit and at rest within AAP — Ansible Vault for static secrets, External Secrets (HashiCorp Vault, AWS Secrets Manager) for rotating ones.
- Review every workflow. Code review for playbooks; design review for workflows. The composition of jobs is where risk concentrates.
- Audit workflow approvers. A workflow that auto-approves “because it usually works” is a fraud risk — a malicious insider with launch rights can move money.
Q&A — 14 Questions
Q1. Do I need AAP, or can I do this with plain ansible-playbook + GitHub Actions?
For 1–10 engineers and < 1,000 hosts, plain Ansible + GitHub Actions + a shared notification channel works fine. AAP shines at: multi-team coordination, RBAC across teams, automation mesh for network-isolated environments, and central audit. If you’re spending more time on coordination than playbook authoring, switch to AAP (or AWX, the open-source upstream).
Q2. AAP vs AWX vs Ansible Tower — what’s the difference? Tower was the old name. AAP is the supported Red Hat product (subscription required). AWX is the upstream open-source project, no support, faster-moving. Same UI, same workflow concepts. Most regulated industries pick AAP for support and vendor accountability.
Q3. Can a workflow span multiple AAP instances?
Not directly — but AAP’s API allows one workflow to launch a Job Template on a peer instance via the tower-cli or HTTP API. Pattern: a “global” AAP launches “regional” AAP workflows. Less common since automation mesh removes most of the need.
Q4. How does AAP handle execution environments (EEs)?
EEs are container images with the collections + Python deps each playbook needs. AAP ships with default EEs, you build custom ones with ansible-builder, push to a registry, and configure Job Templates to use them. Each Job runs in a fresh EE container — no state carries over.
Q5. Should I run a single workflow that touches all clouds, or one workflow per cloud? Per cloud for simple changes (deploy a config update). Cross-cloud for changes that have actual cross-cloud dependencies (a DR exercise that fails over from AWS to Azure).
Q6. How do I handle a change where AWS succeeds but Azure fails halfway?
Workflow on_failure branches that do best-effort rollback in each environment. This is partial — true atomic distributed transactions don’t exist. The discipline is: design changes to be reversible, snapshot before, fail fast.
Q7. Can I use Terraform alongside Ansible in a workflow?
Yes — cloud.terraform.terraform module runs Terraform from Ansible, and cloud.terraform.terraform_state reads Terraform state as inventory. Common pattern: Terraform creates infra, Ansible configures it. Or AAP’s Workflow Template chains a Terraform Cloud run + an Ansible Job Template. See the “Provision with Terraform, Configure with Ansible” section above.
Q8. How do I audit who approved a workflow? AAP records the approving user in the workflow event log. Ship that to Splunk/Elastic with the change ticket — that’s your “who approved it” trail.
Q9. What’s the right way to handle “emergency change” workflows? A separate Workflow Template with shorter approval timeout, scoped credentials (only what the emergency needs), and a post-change required audit task that posts to a #change-emergency channel. The emergency path should be more documented than the normal path, not less.
Q10. How do I orchestrate a DB schema migration across regions?
- Take backups (snapshot all regions in parallel). 2. Run migration on canary region. 3. Run validation. 4. If valid, replay migration on remaining regions. 5. Validate end-to-end. Each step is a Job Template; the workflow chains them with explicit gates.
Q11. Workflow ran, jobs succeeded, but the system is in a bad state. What now?
This is the gap between “task succeeded” and “outcome correct.” Bridge it with explicit validation Job Templates after each change, and end-to-end synthetic tests in the final workflow node. If validation fails, the workflow’s on_failure runs rollback.
Q12. How do I handle dependencies on external systems (load balancer, DNS, monitoring)? Each external system is a Job Template. A change that requires LB drain → app upgrade → monitoring re-arm becomes a 3-node workflow. Don’t try to put external-system calls inline in your app playbook; keep them as separate, reusable Job Templates.
Q13. What’s “convergence node” in workflows? A node with multiple incoming edges that runs only after all upstream nodes complete. Useful at the end of parallel paths: deploy to AWS and Azure and GCP in parallel, then converge to a single “validate end-to-end” node.
Q14. How big of an org needs this level of orchestration?
~50 engineers and ~5,000 hosts is the inflection point. Below that, plain ansible-playbook + simple inventory works. Above that, the coordination cost of plain Ansible exceeds AAP’s overhead. Plan for AAP when you start hearing “who deployed that?” with no answer.
Quick Check
- What’s the role of automation mesh hop nodes?
- What does
set_statsdo in a Workflow? - What’s the canonical pattern for safe production change?
- Which inventory plugins would you use for AWS, Azure, GCP, vSphere, and K8s?
- What’s a Workflow Approval Node?
- What’s the difference between AAP and AWX?
- Why is identity the hardest problem in hybrid orchestration?
- What’s the right rollback strategy for an Ansible-applied schema change?
- What makes AWS and Azure app servers land in the same Ansible group?
- Where does Terraform stop and Ansible start in the provision/configure division?
Exercise
Design (don’t fully implement — that’s a quarter’s work) a Workflow Template for the following change scenario:
Quarterly OS upgrade: 200 RHEL 8 → RHEL 9 servers across vSphere on-prem, AWS EC2, Azure VMs, and GCP Compute Engine. The servers run a stateful application with a database on AWS RDS. The application has a load balancer in each cloud.
Produce:
- A workflow diagram (mermaid) showing all Job Templates and edges.
- A list of Job Templates with their inventory targets, credential bindings, and instance group bindings.
- The
set_statsdata passed between nodes. - The Approval Node placements.
- The rollback paths for each failure point.
- The audit fields injected via
extra_vars.
This is a real architecture deliverable; spend an hour or two on it. Compare to your real production change processes.
Cert Mapping
- EX374 — Hybrid orchestration is the unspoken final boss. Most exam scenarios touch multiple platforms in one playbook.
- AAP-301 / EX467 — AAP-specific certification covers exactly this material.
- CKA + CKS — K8s portion overlaps; identity federation overlaps.
Glossary
- Aggregator inventory — A directory of multiple inventory sources (static + plugins) merged into one logical inventory.
- Automation mesh — AAP’s distributed execution architecture using control, hop, and execution nodes.
- Workflow Template — An AAP DAG of Job Templates with conditional edges.
- Approval Node — A workflow node that pauses for human approval before continuing.
- set_stats — Ansible mechanism for passing data between Job Templates in a workflow.
- Convergence node — A node with multiple inbound edges that runs after all complete.
- OIDC federation — Identity model where AAP’s K8s ServiceAccount is the root of trust and clouds federate via OIDC.
- Blast radius — The set of resources affected by a change; minimizing it is the goal of canary/ringed deployment.
- Game day — A scheduled exercise to validate rollback / DR procedures.
- FQCN (Fully-Qualified Collection Name) — The full
namespace.collection.modulename (e.g.amazon.aws.aws_ec2) that removes ambiguity about which collection provides a module or plugin. - Dynamic inventory — Inventory generated at runtime by a plugin querying a live source (a cloud API, vSphere, Kubernetes) instead of a static file.
- keyed_groups — An inventory-plugin feature that builds groups from host attributes; using the same
prefix:across clouds merges hosts into one shared group. - Execution Environment (EE) — A container image (built with
ansible-builder) bundling the collections and Python/system deps a job needs; the reproducibility and version-pinning boundary. - Receptor — The overlay-network daemon (default TCP 27199) that automation mesh uses to route signed work units between nodes over mTLS.
- Instance Group — A named pool of execution capacity in AAP; binding an inventory to an instance group routes its jobs to the right mesh location.
- WIF (Workload Identity Federation) — Exchanging a trusted workload’s OIDC token (e.g. AAP’s K8s ServiceAccount) for a short-lived cloud credential, so no static key is stored.
- IRSA (IAM Roles for Service Accounts) — AWS’s mechanism for a Kubernetes ServiceAccount to assume an IAM role via
AssumeRoleWithWebIdentity. - gMSA (group Managed Service Account) — An Active Directory account whose password AD manages automatically, used for Windows automation without a stored password.
- Check mode — Ansible’s dry-run (
--check): tasks report whether they would change something without applying it; support varies per module. - Idempotency — The property that re-running a play converges to the declared state and reports
changed=0on the second run — not “never changes anything.” - Drift — Divergence of real system state from the declared state; detectable by running an idempotent play in check mode and failing on any reported change.
- serial / max_fail_percentage — Play keywords that bound blast radius:
seriallimits how many hosts change per batch;max_fail_percentageaborts when too many in a batch fail. - cloud.terraform.terraform_state — An inventory plugin that builds Ansible inventory directly from Terraform state, the clean handoff from provisioning to configuration.
- Day-0 / Day-1 / Day-2 — Lifecycle shorthand: Day-0 = provision the infrastructure (Terraform); Day-1 = configure it into service (Ansible); Day-2 = operate it — patch, correct drift, coordinated change (Ansible).
Next Steps
You’ve completed the Ansible expert tier — 10 lessons covering every major platform Ansible drives in the enterprise, plus this capstone on weaving them together. The course’s bonus tier (Tier 5) covers automation patterns that generalize beyond Ansible: GitOps, infrastructure-as-code review processes, multi-tool orchestration (Ansible + Terraform + Pulumi), and the senior-engineering questions you’ll face in role design — when not to use Ansible, when to migrate off it, and how to evaluate the next-generation tools that will eventually replace it. But what you’ve learned in these four tiers is enough to handle any real-world Ansible challenge in any real production environment.