Introduction
Terraform provisions infrastructure; Ansible configures it. This isn't a versus — it's a pipeline. Terraform creates the VMs, networks, load balancers, and DNS records. Ansible installs packages, deploys applications, hardens security, and manages ongoing configuration. Together they provide full-stack infrastructure as code, from cloud API calls to application-ready servers.
This guide covers the integration patterns used in production enterprise environments.
When to Use Which
| Task | Tool | Why |
|---|---|---|
| Create VPC, subnets, security groups | Terraform | Declarative cloud resource management |
| Provision EC2/VM instances | Terraform | Cloud provider API, state tracking |
| Install packages, configure services | Ansible | Agentless, idempotent configuration |
| Deploy application code | Ansible | Rolling updates, handlers, templates |
| Manage DNS records | Terraform | State-tracked, plan/apply workflow |
| Rotate certificates | Ansible | Procedural workflow with handlers |
| Database schema migrations | Ansible | Sequential, host-targeted execution |
| Kubernetes cluster creation | Terraform | Infrastructure lifecycle |
| Kubernetes app deployment | Ansible / kubectl | Configuration management |
Architecture
┌──────────┐ ┌───────────────┐ ┌──────────────────┐ ┌─────────────┐
│ Git Push │───▶│ CI/CD │───▶│ Terraform │───▶│ Ansible │
│ │ │ (GitHub │ │ Plan → Apply │ │ Configure │
│ │ │ Actions) │ │ │ │ Deploy │
└──────────┘ └───────────────┘ │ Outputs: │ │ │
│ - IP addresses │───▶│ Dynamic │
│ - DNS names │ │ Inventory │
│ - Security IDs │ │ │
└──────────────────┘ └─────────────┘
Pattern 1: Terraform Outputs → Ansible Dynamic Inventory
Terraform Configuration
# main.tf
resource "aws_instance" "web" {
count = 3
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
subnet_id = aws_subnet.private.id
key_name = aws_key_pair.deploy.key_name
vpc_security_group_ids = [aws_security_group.web.id]
tags = {
Name = "web-${count.index + 1}"
Environment = var.environment
Role = "webserver"
ManagedBy = "terraform"
}
}
resource "aws_instance" "db" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "r5.large"
subnet_id = aws_subnet.private.id
key_name = aws_key_pair.deploy.key_name
tags = {
Name = "db-primary"
Environment = var.environment
Role = "database"
ManagedBy = "terraform"
}
}
# Outputs for Ansible
output "web_ips" {
value = aws_instance.web[*].private_ip
}
output "db_ip" {
value = aws_instance.db.private_ip
}
output "web_public_dns" {
value = aws_instance.web[*].public_dns
}
Generate Ansible Inventory from Terraform State
#!/usr/bin/env python3
# terraform_inventory.py — Dynamic inventory from Terraform state
import json
import subprocess
import sys
def get_terraform_output():
result = subprocess.run(
["terraform", "output", "-json"],
capture_output=True, text=True,
cwd="/path/to/terraform"
)
return json.loads(result.stdout)
def main():
outputs = get_terraform_output()
inventory = {
"webservers": {
"hosts": outputs["web_ips"]["value"],
"vars": {
"ansible_user": "ec2-user",
"ansible_ssh_private_key_file": "~/.ssh/deploy-key.pem"
}
},
"databases": {
"hosts": [outputs["db_ip"]["value"]],
"vars": {
"ansible_user": "ec2-user",
"ansible_ssh_private_key_file": "~/.ssh/deploy-key.pem"
}
},
"_meta": {
"hostvars": {}
}
}
# Add per-host variables
for i, ip in enumerate(outputs["web_ips"]["value"]):
inventory["_meta"]["hostvars"][ip] = {
"web_server_id": i + 1,
"public_dns": outputs["web_public_dns"]["value"][i]
}
print(json.dumps(inventory, indent=2))
if __name__ == "__main__":
main()
# Use the dynamic inventory
ansible-playbook -i terraform_inventory.py configure-web.yml
Alternative: AWS EC2 Dynamic Inventory Plugin
# inventory/aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:ManagedBy: terraform
tag:Environment: production
keyed_groups:
- key: tags.Role
prefix: role
hostnames:
- private-ip-address
compose:
ansible_user: "'ec2-user'"
ansible_ssh_private_key_file: "'~/.ssh/deploy-key.pem'"
Pattern 2: Full CI/CD Pipeline
GitHub Actions Workflow
# .github/workflows/deploy.yml
name: Infrastructure Deploy
on:
push:
branches: [main]
paths:
- 'terraform/**'
- 'ansible/**'
jobs:
terraform:
runs-on: ubuntu-latest
outputs:
web_ips: ${{ steps.output.outputs.web_ips }}
db_ip: ${{ steps.output.outputs.db_ip }}
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.7.0
- name: Terraform Init
working-directory: terraform/
run: terraform init
- name: Terraform Plan
working-directory: terraform/
run: terraform plan -out=tfplan
- name: Terraform Apply
working-directory: terraform/
run: terraform apply -auto-approve tfplan
- name: Export Outputs
id: output
working-directory: terraform/
run: |
echo "web_ips=$(terraform output -json web_ips)" >> $GITHUB_OUTPUT
echo "db_ip=$(terraform output -raw db_ip)" >> $GITHUB_OUTPUT
ansible:
needs: terraform
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Ansible
run: pip install ansible boto3
- name: Wait for instances to be ready
run: |
for ip in $(echo '${{ needs.terraform.outputs.web_ips }}' | jq -r '.[]'); do
until ssh -o ConnectTimeout=5 -o StrictHostKeyChecking=no ec2-user@$ip true 2>/dev/null; do
echo "Waiting for $ip..."
sleep 10
done
done
- name: Run Ansible Playbook
run: |
ansible-playbook -i inventory/aws_ec2.yml \
ansible/site.yml \
--extra-vars "deploy_version=${{ github.sha }}"
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
Pattern 3: Terraform Provisioner (Simple Cases)
For quick setups, Terraform can invoke Ansible directly — though CI/CD is preferred for production.
resource "aws_instance" "app" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t3.medium"
provisioner "remote-exec" {
inline = ["echo 'SSH is ready'"]
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/deploy-key.pem")
host = self.public_ip
}
}
provisioner "local-exec" {
command = <<-EOT
ANSIBLE_HOST_KEY_CHECKING=False ansible-playbook \
-i '${self.public_ip},' \
-u ec2-user \
--private-key ~/.ssh/deploy-key.pem \
ansible/configure-app.yml
EOT
}
}
Pattern 4: Shared State for Ansible Variables
# Terraform writes variables that Ansible needs
resource "local_file" "ansible_vars" {
content = yamlencode({
vpc_id = aws_vpc.main.id
db_endpoint = aws_db_instance.main.endpoint
redis_endpoint = aws_elasticache_cluster.main.cache_nodes[0].address
s3_bucket = aws_s3_bucket.app.id
lb_dns = aws_lb.app.dns_name
environment = var.environment
deploy_region = var.aws_region
})
filename = "${path.module}/../ansible/group_vars/all/terraform_outputs.yml"
}
# ansible/group_vars/all/terraform_outputs.yml (auto-generated)
vpc_id: vpc-0abc123def456
db_endpoint: mydb.cluster-abc123.us-east-1.rds.amazonaws.com:5432
redis_endpoint: my-redis.abc123.0001.use1.cache.amazonaws.com
s3_bucket: my-app-assets-prod
lb_dns: my-app-lb-123456.us-east-1.elb.amazonaws.com
environment: production
deploy_region: us-east-1
# Ansible playbook using Terraform outputs automatically
---
- name: Configure web application
hosts: role_webserver
tasks:
- name: Deploy application configuration
ansible.builtin.template:
src: app.conf.j2
dest: /etc/myapp/app.conf
# Uses db_endpoint, redis_endpoint, s3_bucket from terraform_outputs.yml
Project Structure
infrastructure/
├── terraform/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ ├── terraform.tfvars
│ └── modules/
│ ├── networking/
│ ├── compute/
│ └── database/
├── ansible/
│ ├── site.yml
│ ├── inventory/
│ │ └── aws_ec2.yml
│ ├── group_vars/
│ │ ├── all/
│ │ │ └── terraform_outputs.yml ← auto-generated
│ │ ├── role_webserver.yml
│ │ └── role_database.yml
│ ├── roles/
│ │ ├── common/
│ │ ├── webserver/
│ │ ├── database/
│ │ └── monitoring/
│ └── playbooks/
│ ├── deploy.yml
│ ├── rollback.yml
│ └── maintenance.yml
├── .github/workflows/
│ └── deploy.yml
└── Makefile
Makefile for Local Development
.PHONY: plan apply configure deploy destroy
plan:
cd terraform && terraform plan
apply:
cd terraform && terraform apply -auto-approve
configure:
cd ansible && ansible-playbook -i inventory/aws_ec2.yml site.yml
deploy: apply configure
destroy:
cd terraform && terraform destroy -auto-approve
Troubleshooting
Terraform Apply Succeeds but Ansible Fails
Instances need time to boot. Add a wait step:
- name: Wait for SSH
ansible.builtin.wait_for:
host: "{{ ansible_host }}"
port: 22
delay: 30
timeout: 300
delegate_to: localhost
State Drift Between Tools
Run Terraform with -refresh-only to detect drift, then re-run Ansible to enforce configuration:
terraform plan -refresh-only # Detect infrastructure drift
ansible-playbook site.yml --check --diff # Detect config drift
Related Articles
- Ansible vs Terraform Comparison
- Ansible Automation Platform Guide
- AWS EC2 AMI Module
- Ansible Best Practices Guide
- Managing Credentials with HashiCorp Vault
Conclusion
Terraform provisions resources (VMs, networks, databases, DNS); Ansible configures them (packages, services, applications, security). Connect them through dynamic inventory (AWS EC2 plugin or Terraform output scripts), shared variable files, or CI/CD pipelines. For enterprise deployments, use the CI/CD pattern (GitHub Actions / GitLab CI) with Terraform outputs feeding Ansible inventory — it gives you plan reviews, approval gates, and full auditability of both provisioning and configuration changes.
Related
For a production-focused walkthrough, see Luca Berton's guide on Terraform vs Ansible: when to use which.