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

TaskToolWhy
Create VPC, subnets, security groupsTerraformDeclarative cloud resource management
Provision EC2/VM instancesTerraformCloud provider API, state tracking
Install packages, configure servicesAnsibleAgentless, idempotent configuration
Deploy application codeAnsibleRolling updates, handlers, templates
Manage DNS recordsTerraformState-tracked, plan/apply workflow
Rotate certificatesAnsibleProcedural workflow with handlers
Database schema migrationsAnsibleSequential, host-targeted execution
Kubernetes cluster creationTerraformInfrastructure lifecycle
Kubernetes app deploymentAnsible / kubectlConfiguration 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

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.

For a production-focused walkthrough, see Luca Berton's guide on Terraform vs Ansible: when to use which.