Ansible AWX vs AAP: Key Differences for DevOps

Ansible AWX vs AAP, Terraform Integration & CI/CD Pipelines


1. Ansible AWX vs AAP

What is AWX?

AWX is the open-source upstream project for Ansible Automation Platform — think of it like Fedora is to RHEL. It provides a web UI, REST API, and job scheduling on top of Ansible.

What is AAP?

AAP (Ansible Automation Platform) is the Red Hat enterprise product built from AWX — adds support, stability, Automation Hub, EDA, and a hardened release cycle.

Comparison Table
FeatureAWXAAP
CostFree / open sourcePaid subscription
SupportCommunity onlyRed Hat support
Release cycleRapid / unstableStable / tested
Automation HubNoYes (certified content)
Event Driven AnsibleLimitedFull EDA component
Execution EnvironmentsYesYes + certified EEs
Upgrade pathManual / complexSupported upgrade
RBACBasicAdvanced
AnalyticsNoYes (Automation Analytics)
Air-gap installDifficultSupported
Best forLabs / dev / learningEnterprise production

AAP Architecture — Full Picture
┌─────────────────────────────────────────────────────────────┐
│ Ansible Automation Platform │
│ │
│ ┌─────────────────┐ ┌──────────────────────────────┐ │
│ │ Automation Hub │ │ Automation Controller │ │
│ │ │ │ │ │
│ │ - Certified │ │ ┌────────────────────────┐ │ │
│ │ Collections │ │ │ Job Templates │ │ │
│ │ - Private repo │◄────┤ │ Workflows │ │ │
│ │ - Sync from │ │ │ Schedules │ │ │
│ │ Galaxy │ │ │ Credentials │ │ │
│ └─────────────────┘ │ │ Inventories │ │ │
│ │ │ Projects (SCM) │ │ │
│ ┌─────────────────┐ │ └────────────────────────┘ │ │
│ │ Event Driven │ └──────────────────────────────┘ │
│ │ Ansible (EDA) │ │ │
│ │ │ ┌─────────────▼────────────────┐ │
│ │ - Rulebooks │ │ Execution Environments │ │
│ │ - Event sources │ │ (containerized Ansible) │ │
│ │ - Kafka/webhook │ └──────────────────────────────┘ │
│ └─────────────────┘ │
│ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Automation Analytics (optional) │ │
│ │ Job history, savings calculator, host metrics │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘

AWX / AAP Key Concepts Deep Dive
Organizations & RBAC
Organization
├── Teams
│ ├── Team: Developers
│ │ └── Role: Execute (Job Templates only)
│ └── Team: Ops
│ └── Role: Admin (all resources)
├── Users
├── Inventories
├── Projects
├── Job Templates
└── Credentials
Credential Types
# Built-in credential types:
Machine # SSH key/password for managed nodes
Source Control # Git username/token/SSH key
Vault # Ansible Vault password
AWS # Access key + secret
GCP # Service account JSON
Azure # Client ID + secret
OpenShift/K8s # Kubeconfig / bearer token
HashiCorp Vault # AppRole / token
CyberArk # API credentials
Job Template — Key Fields
Name: Deploy App
Inventory: Production Inventory
Project: App Deployment (Git)
Playbook: deploy.yml
Credentials:
- Machine: prod-ssh-key
- Vault: prod-vault-pass
- AWS: prod-aws-creds
Extra Vars:
env: production
version: "{{ tower_job_id }}"
Verbosity: 1 (Verbose)
Job Tags: deploy,config
Limit: webservers # Run only on webservers group
Concurrent: false # Prevent parallel runs
Timeout: 3600
Survey: enabled # Runtime input form
Workflow Job Templates
┌─────────────┐ ┌──────────────┐ ┌─────────────────┐
│ Run Tests │────►│ Deploy App │────►│ Smoke Tests │
└─────────────┘ └──────────────┘ └────────┬────────┘
│ │
on_failure on_success
│ │
┌──────▼──────┐ ┌────────▼────────┐
│ Rollback │ │ Notify Slack │
└─────────────┘ └─────────────────┘
# Workflow node conditions:
on_success # Run next node if this one succeeds
on_failure # Run next node if this one fails
always # Run next node regardless of result
Surveys — Runtime Input
# Survey fields on a Job Template
- variable: app_version
question_name: "App Version to Deploy"
type: text
required: true
default: "latest"
- variable: environment
question_name: "Target Environment"
type: multiplechoice
choices:
- dev
- staging
- production
required: true
- variable: replica_count
question_name: "Number of Replicas"
type: integer
min: 1
max: 10
default: 2

AAP REST API — Automation

# Launch a job template via API
curl -X POST \
https://aap.example.com/api/v2/job_templates/42/launch/ \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d '{
"extra_vars": {
"env": "production",
"version": "2.1.0"
},
"limit": "webservers"
}'
# Check job status
curl -X GET \
https://aap.example.com/api/v2/jobs/1234/ \
-H "Authorization: Bearer $TOKEN"
# Get job output
curl -X GET \
https://aap.example.com/api/v2/jobs/1234/stdout/?format=txt \
-H "Authorization: Bearer $TOKEN"

2. Ansible + Terraform Integration
Why Combine Ansible and Terraform?
┌────────────────────────────────────────────────────────┐
│ Infrastructure Lifecycle │
│ │
│ ┌───────────────┐ ┌──────────────────────┐ │
│ │ Terraform │ │ Ansible │ │
│ │ │ │ │ │
│ │ - Provision │─────────►│ - Configure OS │ │
│ │ VMs/cloud │ │ - Install software │ │
│ │ - Networks │ │ - Deploy apps │ │
│ │ - Storage │ │ - Manage users │ │
│ │ - Kubernetes │ │ - Day-2 operations │ │
│ │ │ │ │ │
│ │ "Build it" │ │ "Configure it" │ │
│ └───────────────┘ └──────────────────────┘ │
│ │ │ │
│ └────────────────────────────┘ │
│ Works together │
└────────────────────────────────────────────────────────┘
Pattern 1 — Terraform Provisions, Ansible Configures
# main.tf — Terraform creates EC2 instances
resource "aws_instance" "web" {
count = 3
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
tags = {
Name = "web-${count.index + 1}"
Role = "webserver"
Environment = "production"
}
# Run Ansible after instance is created
provisioner "local-exec" {
command = <<-EOT
sleep 30 # Wait for SSH
ansible-playbook \
-i '${self.public_ip},' \
-u ec2-user \
--private-key ~/.ssh/prod-key.pem \
playbooks/configure-webserver.yml \
--extra-vars "env=production"
EOT
}
}
# Output IPs for Ansible dynamic inventory
output "web_ips" {
value = aws_instance.web[*].public_ip
}
Pattern 2 — Terraform Output → Ansible Inventory
# outputs.tf
output "web_servers" {
value = {
for idx, instance in aws_instance.web :
"web-${idx + 1}" => {
ip = instance.public_ip
private = instance.private_ip
az = instance.availability_zone
}
}
}
#!/usr/bin/env python3
# dynamic_inventory.py — reads Terraform state for Ansible inventory
import json
import subprocess
import sys
def get_terraform_output():
result = subprocess.run(
["terraform", "output", "-json"],
capture_output=True, text=True
)
return json.loads(result.stdout)
def build_inventory():
tf_output = get_terraform_output()
web_servers = tf_output["web_servers"]["value"]
inventory = {
"webservers": {
"hosts": list(web_servers.keys()),
},
"_meta": {
"hostvars": {
name: {
"ansible_host": data["ip"],
"private_ip": data["private"],
"az": data["az"],
"ansible_user": "ec2-user"
}
for name, data in web_servers.items()
}
}
}
return inventory
print(json.dumps(build_inventory()))
# Use dynamic inventory
ansible-playbook -i dynamic_inventory.py configure.yml
Pattern 3 — Ansible Calling Terraform
# playbook — use Ansible to run Terraform
- name: Provision infrastructure with Terraform
hosts: localhost
gather_facts: false
tasks:
- name: Terraform init
community.general.terraform:
project_path: ./terraform
state: present
force_init: true
backend_config:
bucket: my-tf-state
key: prod/terraform.tfstate
region: us-east-1
variables:
environment: production
instance_count: "3"
instance_type: t3.medium
register: tf_result
- name: Show outputs
debug:
var: tf_result.outputs
- name: Add new hosts to inventory
add_host:
name: "{{ item.key }}"
ansible_host: "{{ item.value.ip }}"
groups: webservers
loop: "{{ tf_result.outputs.web_servers.value | dict2items }}"
- name: Configure newly provisioned servers
hosts: webservers
become: true
roles:
- common
- webserver
Pattern 4 — Shared State via Terraform Remote State
# data.tf — read existing Terraform state in another playbook
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "my-tf-state"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
# Use VPC ID from network state
resource "aws_instance" "web" {
subnet_id = data.terraform_remote_state.network.outputs.subnet_id
}
# Ansible reading Terraform state directly
- name: Read Terraform state
hosts: localhost
tasks:
- name: Get TF state from S3
amazon.aws.s3_object:
bucket: my-tf-state
object: prod/terraform.tfstate
dest: /tmp/terraform.tfstate
mode: get
- name: Parse state
set_fact:
tf_state: "{{ lookup('file', '/tmp/terraform.tfstate') | from_json }}"
- name: Extract outputs
set_fact:
web_ips: >-
{{ tf_state.outputs.web_ips.value }}

3. CI/CD Pipelines with Ansible

Pipeline Patterns
┌──────────────────────────────────────────────────────────────┐
│ CI/CD Pipeline │
│ │
│ Code Push │
│ │ │
│ ▼ │
│ ┌──────┐ ┌────────┐ ┌──────────┐ ┌────────────────┐ │
│ │ SCM │──►│ CI │──►│ Ansible │──►│ Production │ │
│ │(Git) │ │(build/ │ │(deploy/ │ │ (live env) │ │
│ │ │ │ test) │ │ config) │ │ │ │
│ └──────┘ └────────┘ └──────────┘ └────────────────┘ │
│ │
└──────────────────────────────────────────────────────────────┘

Jenkins + Ansible Pipeline
// Jenkinsfile
pipeline {
agent any
environment {
ANSIBLE_HOST_KEY_CHECKING = 'False'
VAULT_PASSWORD_FILE = credentials('ansible-vault-pass')
}
parameters {
choice(
name: 'ENVIRONMENT',
choices: ['dev', 'staging', 'production'],
description: 'Target environment'
)
string(
name: 'APP_VERSION',
defaultValue: 'latest',
description: 'Version to deploy'
)
}
stages {
stage('Checkout') {
steps {
git branch: 'main',
url: 'https://github.com/myorg/ansible-playbooks.git',
credentialsId: 'github-token'
}
}
stage('Lint & Syntax Check') {
steps {
sh '''
pip install ansible-lint
ansible-lint playbooks/deploy.yml
ansible-playbook playbooks/deploy.yml --syntax-check
'''
}
}
stage('Molecule Tests') {
when {
branch 'main'
}
steps {
sh '''
pip install molecule molecule-docker
cd roles/myapp
molecule test
'''
}
}
stage('Deploy to Dev') {
when {
expression { params.ENVIRONMENT == 'dev' }
}
steps {
sh """
ansible-playbook playbooks/deploy.yml \
-i inventories/dev/hosts.yml \
--vault-password-file ${VAULT_PASSWORD_FILE} \
-e app_version=${params.APP_VERSION} \
-e env=dev
"""
}
}
stage('Deploy to Staging') {
when {
expression { params.ENVIRONMENT == 'staging' }
}
steps {
sh """
ansible-playbook playbooks/deploy.yml \
-i inventories/staging/hosts.yml \
--vault-password-file ${VAULT_PASSWORD_FILE} \
-e app_version=${params.APP_VERSION} \
-e env=staging
"""
}
post {
success {
// Trigger smoke tests
sh 'ansible-playbook playbooks/smoke-tests.yml -i inventories/staging/hosts.yml'
}
}
}
stage('Approval') {
when {
expression { params.ENVIRONMENT == 'production' }
}
steps {
timeout(time: 1, unit: 'HOURS') {
input message: 'Deploy to Production?',
ok: 'Deploy',
submitter: 'ops-team'
}
}
}
stage('Deploy to Production') {
when {
expression { params.ENVIRONMENT == 'production' }
}
steps {
sh """
ansible-playbook playbooks/deploy.yml \
-i inventories/production/hosts.yml \
--vault-password-file ${VAULT_PASSWORD_FILE} \
-e app_version=${params.APP_VERSION} \
-e env=production \
--diff
"""
}
}
}
post {
success {
slackSend channel: '#deployments',
message: "✅ Deploy ${params.APP_VERSION} to ${params.ENVIRONMENT} succeeded"
}
failure {
slackSend channel: '#deployments',
message: "❌ Deploy ${params.APP_VERSION} to ${params.ENVIRONMENT} FAILED"
// Auto rollback on production failure
script {
if (params.ENVIRONMENT == 'production') {
sh 'ansible-playbook playbooks/rollback.yml -i inventories/production/hosts.yml'
}
}
}
}
}

GitLab CI + Ansible
# .gitlab-ci.yml
stages:
- lint
- test
- deploy-dev
- deploy-staging
- deploy-prod
variables:
ANSIBLE_HOST_KEY_CHECKING: "false"
PIP_CACHE_DIR: "$CI_PROJECT_DIR/.pip-cache"
# Reusable anchor for Ansible setup
.ansible_setup: &ansible_setup
image: python:3.11-slim
before_script:
- pip install ansible ansible-lint molecule[docker] --quiet
- ansible-galaxy collection install -r requirements.yml
- echo "$VAULT_PASSWORD" > /tmp/vault_pass
# Lint stage
ansible-lint:
<<: *ansible_setup
stage: lint
script:
- ansible-lint
- ansible-playbook site.yml --syntax-check -i inventories/dev/
# Molecule tests
molecule-test:
<<: *ansible_setup
stage: test
services:
- docker:dind
variables:
DOCKER_HOST: tcp://docker:2376
script:
- |
for role in roles/*/; do
echo "Testing role: $role"
cd "$role"
molecule test
cd -
done
only:
- merge_requests
- main
# Deploy to dev
deploy-dev:
<<: *ansible_setup
stage: deploy-dev
script:
- |
ansible-playbook deploy.yml \
-i inventories/dev/hosts.yml \
--vault-password-file /tmp/vault_pass \
-e app_version=$CI_COMMIT_SHA \
-e env=dev
environment:
name: development
url: https://dev.example.com
only:
- main
# Deploy to staging
deploy-staging:
<<: *ansible_setup
stage: deploy-staging
script:
- |
ansible-playbook deploy.yml \
-i inventories/staging/hosts.yml \
--vault-password-file /tmp/vault_pass \
-e app_version=$CI_COMMIT_TAG \
-e env=staging
environment:
name: staging
url: https://staging.example.com
only:
- tags
# Deploy to production — manual gate
deploy-prod:
<<: *ansible_setup
stage: deploy-prod
script:
- |
ansible-playbook deploy.yml \
-i inventories/production/hosts.yml \
--vault-password-file /tmp/vault_pass \
-e app_version=$CI_COMMIT_TAG \
-e env=production \
--diff
environment:
name: production
url: https://example.com
when: manual # Manual trigger
allow_failure: false
only:
- tags

GitHub Actions + Ansible
# .github/workflows/deploy.yml
name: Deploy with Ansible
on:
push:
branches: [main]
workflow_dispatch:
inputs:
environment:
description: 'Environment'
required: true
type: choice
options: [dev, staging, production]
version:
description: 'Version to deploy'
required: true
default: 'latest'
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
- name: Install Ansible
run: pip install ansible ansible-lint
- name: Run ansible-lint
run: ansible-lint
- name: Syntax check
run: ansible-playbook deploy.yml --syntax-check -i inventories/dev/
molecule-test:
runs-on: ubuntu-latest
needs: lint
steps:
- uses: actions/checkout@v4
- name: Install dependencies
run: pip install ansible molecule[docker]
- name: Run Molecule tests
run: |
for role in roles/*/; do
cd "$role" && molecule test && cd -
done
deploy:
runs-on: ubuntu-latest
needs: molecule-test
environment: ${{ github.event.inputs.environment || 'dev' }}
steps:
- uses: actions/checkout@v4
- name: Install Ansible + collections
run: |
pip install ansible
ansible-galaxy collection install -r requirements.yml
- name: Write vault password
run: echo "${{ secrets.VAULT_PASSWORD }}" > /tmp/vault_pass
- name: Write SSH key
run: |
mkdir -p ~/.ssh
echo "${{ secrets.SSH_PRIVATE_KEY }}" > ~/.ssh/id_rsa
chmod 600 ~/.ssh/id_rsa
- name: Deploy
run: |
ansible-playbook deploy.yml \
-i inventories/${{ github.event.inputs.environment }}/hosts.yml \
--vault-password-file /tmp/vault_pass \
--private-key ~/.ssh/id_rsa \
-e app_version=${{ github.event.inputs.version }} \
-e env=${{ github.event.inputs.environment }}
- name: Cleanup secrets
if: always()
run: |
rm -f /tmp/vault_pass ~/.ssh/id_rsa

Ansible + AAP in CI/CD Pipeline
# aap_trigger.py trigger AAP job from CI pipeline
import requests
import time
import sys
AAP_URL = "https://aap.example.com"
TOKEN = os.environ["AAP_TOKEN"]
JOB_TEMPLATE_ID = 42
def launch_job(extra_vars):
response = requests.post(
f"{AAP_URL}/api/v2/job_templates/{JOB_TEMPLATE_ID}/launch/",
headers={"Authorization": f"Bearer {TOKEN}"},
json={"extra_vars": extra_vars},
verify=True
)
response.raise_for_status()
return response.json()["id"]
def wait_for_job(job_id, timeout=3600):
start = time.time()
while time.time() - start < timeout:
response = requests.get(
f"{AAP_URL}/api/v2/jobs/{job_id}/",
headers={"Authorization": f"Bearer {TOKEN}"}
)
job = response.json()
status = job["status"]
print(f"Job {job_id} status: {status}")
if status == "successful":
return True
elif status in ["failed", "error", "canceled"]:
return False
time.sleep(15)
raise TimeoutError(f"Job {job_id} timed out")
# Usage in CI
job_id = launch_job({
"env": "production",
"version": os.environ["APP_VERSION"]
})
success = wait_for_job(job_id)
sys.exit(0 if success else 1)

Deployment Strategies with Ansible
Rolling Deploy
- name: Rolling deployment
hosts: webservers
serial: 1 # One host at a time
# serial: "25%" # Or 25% at a time
max_fail_percentage: 0 # Stop on any failure
pre_tasks:
- name: Remove from load balancer
uri:
url: "http://lb.example.com/api/drain/{{ inventory_hostname }}"
method: POST
delegate_to: localhost
roles:
- deploy-app
post_tasks:
- name: Health check
uri:
url: "http://{{ ansible_host }}:8080/health"
status_code: 200
retries: 5
delay: 10
- name: Re-add to load balancer
uri:
url: "http://lb.example.com/api/enable/{{ inventory_hostname }}"
method: POST
delegate_to: localhost
Blue-Green Deploy
- name: Blue-Green deployment
hosts: localhost
vars:
active_env: "{{ lookup('file', '/tmp/active_env') | default('blue') }}"
new_env: "{{ 'green' if active_env == 'blue' else 'blue' }}"
tasks:
- name: Deploy to inactive environment
include_role:
name: deploy-app
vars:
target_hosts: "{{ new_env }}_servers"
app_version: "{{ deploy_version }}"
- name: Smoke test new environment
uri:
url: "http://{{ new_env }}.internal/health"
status_code: 200
- name: Switch load balancer to new environment
uri:
url: http://lb.example.com/api/switch
method: POST
body_format: json
body:
active: "{{ new_env }}"
- name: Record new active environment
copy:
content: "{{ new_env }}"
dest: /tmp/active_env

Interview Quick-Fire

  • AWX vs AAP main difference? AWX is open-source/community; AAP is enterprise with Red Hat support, Automation Hub, and EDA.
  • How does AAP store credentials securely? Credentials are encrypted at rest and never exposed in job output — only injected into the execution environment at runtime.
  • What is an Execution Environment? A container image bundling Ansible, collections, and Python dependencies — ensures consistent runtime across nodes.
  • How do you trigger AAP from a CI pipeline? Via the REST API — POST to /api/v2/job_templates/<id>/launch/ with a bearer token.
  • Terraform vs Ansible — which for provisioning? Terraform for cloud infrastructure (stateful, plan/apply). Ansible for configuration management (agentless, push-based). Use both together.
  • What is the Terraform provisioner risk? Provisioners are a last resort — they break idempotency. Prefer separate Ansible invocation after terraform apply.
  • How do you pass Terraform outputs to Ansible? Via dynamic inventory script reading terraform output -json, or add_host module after community.general.terraform task.
  • What is serial in a playbook? Controls rolling update batch size — serial: 1 updates one host at a time; serial: "25%" does 25% at a time.
  • How do you prevent secrets appearing in CI logs? Use Ansible Vault, store passwords in CI secret variables, use no_log: true on sensitive tasks.
# no_log example
- name: Set database password
mysql_user:
name: appuser
password: "{{ db_password }}"
no_log: true # Suppresses task output in logs

Leave a Reply