Terraform and Ansible are complementary tools that address different phases of infrastructure automation. Terraform's declarative model excels at provisioning and managing cloud infrastructure resources — VPCs, EC2 instances, RDS databases, S3 buckets, IAM roles, security groups — resources that are created and deleted through cloud provider APIs. Ansible's procedural model excels at configuring the operating system and software running on those resources — installing packages, writing configuration files, managing services, creating users, and deploying application code. Attempting to do everything in one tool produces worse results than using each tool for its strength: Terraform-based configuration management is possible (using user_data scripts, remote-exec provisioners) but brittle and difficult to iterate on; Ansible-based infrastructure provisioning is possible (using the AWS collection) but lacks Terraform's state management and drift detection.
The provision-then-configure pattern has three integration points that must be carefully designed. First, the handoff mechanism: how does Ansible know which hosts Terraform created? The cleanest answer is tags — Terraform tags EC2 instances with Project, Environment, and Role tags; the Ansible dynamic EC2 inventory plugin discovers those instances by tag. This decoupling means Terraform does not need to call Ansible, and Ansible does not need Terraform — they share only the tagging convention. Second, the ordering and dependencies: Terraform must complete provisioning before Ansible runs; EC2 instances must be fully initialised and SSM-registered before Ansible can connect. CI pipelines enforce this ordering with explicit 'terraform apply' → 'wait for SSM' → 'ansible-playbook' steps. Third, ongoing lifecycle management: after initial provisioning and configuration, how are application updates, OS patches, and configuration changes applied? Ansible handles day-two operations (deploying new app versions, applying security patches, rotating credentials) while Terraform handles infrastructure changes (scaling, resizing, networking changes). Keeping each tool responsible for its domain prevents the operational confusion of having two tools fight over the same resource.
# complete CI/CD pipeline — provision (Terraform) then configure (Ansible)
# .github/workflows/full-deploy.yml
name: Full Infrastructure Deployment
on:
push:
branches: [main]
jobs:
# ── Phase 1: Provision Infrastructure ─────────────────────────────────────
terraform:
name: Provision with Terraform
runs-on: ubuntu-latest
outputs:
instance_ids: ${{ steps.outputs.outputs.instance_ids }}
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/cricket-terraform-role
aws-region: ap-south-1
- uses: hashicorp/setup-terraform@v3
with: { terraform_version: '1.6.4' }
- name: Terraform Init and Apply
working-directory: environments/production
run: |
terraform init -input=false
terraform apply -input=false -auto-approve -no-color
- name: Capture Terraform Outputs
id: outputs
working-directory: environments/production
run: |
INSTANCE_IDS=$(terraform output -json instance_ids | jq -r '.[]' | tr '\n' ',')
echo "instance_ids=${INSTANCE_IDS}" >> $GITHUB_OUTPUT
# ── Wait for SSM registration ──────────────────────────────────────────────
wait_for_ssm:
name: Wait for SSM Agent Registration
runs-on: ubuntu-latest
needs: terraform
steps:
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/cricket-deploy-role
aws-region: ap-south-1
- name: Wait for all instances to register with SSM
run: |
INSTANCE_IDS=($(echo '${{ needs.terraform.outputs.instance_ids }}' | tr ',' ' '))
echo "Waiting for ${#INSTANCE_IDS[@]} instances to register with SSM..."
for INSTANCE_ID in "${INSTANCE_IDS[@]}"; do
echo "Checking ${INSTANCE_ID}..."
timeout 300 bash -c "
until aws ssm describe-instance-information \
--filters Key=InstanceIds,Values=${INSTANCE_ID} \
--query 'InstanceInformationList[0].PingStatus' \
--output text 2>/dev/null | grep -q Online; do
echo 'Waiting for SSM...'
sleep 10
done
"
echo "${INSTANCE_ID} is online"
done
# ── Phase 2: Configure with Ansible ───────────────────────────────────────
ansible:
name: Configure with Ansible
runs-on: ubuntu-latest
needs: [terraform, wait_for_ssm]
steps:
- uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789012:role/cricket-deploy-role
aws-region: ap-south-1
- name: Install Ansible and dependencies
run: |
pip install ansible boto3 botocore
ansible-galaxy collection install amazon.aws community.aws
ansible-galaxy install -r requirements.yml --roles-path vendor/roles
- name: Write vault password
run: |
echo '${{ secrets.ANSIBLE_VAULT_PASSWORD }}' > /tmp/vault-pass
chmod 600 /tmp/vault-pass
- name: Run Ansible playbook
run: |
ansible-playbook \
-i inventory/aws_ec2.yml \
--vault-password-file /tmp/vault-pass \
-e app_version=${{ github.sha }} \
-e environment=production \
playbooks/deploy_cricket_api.yml
- name: Clean up
if: always()
run: rm -f /tmp/vault-pass# Tag conventions for Terraform → Ansible handoff
# In Terraform configuration:
resource "aws_instance" "api" {
count = var.instance_count
tags = {
Name = "cricket-api-${count.index + 1}"
Project = "cricket-analytics" # Ansible ec2 plugin: 'tag:Project': cricket-analytics
Environment = var.environment # Ansible group: tag_Environment_production
Role = "api-server" # Ansible group: tag_Role_api_server
AnsibleManaged = "true" # Easy filter for all Ansible-managed instances
}
}
# In Ansible aws_ec2.yml:
# filters:
# 'tag:AnsibleManaged': 'true'
# 'tag:Project': cricket-analytics
# groups:
# api_servers: "tags.Role == 'api-server'"
# ansible/ansible.cfg — project configuration
[defaults]
host_key_checking = False
roles_path = vendor/roles
stdout_callback = yaml
collections_path = ~/.ansible/collections
[inventory]
enable_plugins = amazon.aws.ec2, yaml, ini
[privilege_escalation]
become = True
become_method = sudo