TL;DR
- Terraform owns cloud resource lifecycle: networks, subnets, compute instances, managed databases, IAM, load balancers, DNS, and anything you need to create, update, and destroy with a tracked dependency graph.
- Ansible owns mutable configuration on existing hosts: packages, config files, services, users, patches, and multi-step deploy orchestration across a fleet.
- The wrong split is using Ansible to provision complex cloud stacks without state tracking, or using Terraform provisioners to configure software inside instances.
- Most brownfield VM stacks use a sequential handoff: Terraform applies infrastructure and exports connection metadata; Ansible consumes those outputs through dynamic inventory. Pick one tool per layer.
Your platform team inherited a repo where Ansible playbooks create VPCs, RDS instances, and security groups, while a separate Terraform stack uses remote-exec provisioners to install Nginx and deploy application code. Neither team can answer two questions reliably: what exists in the cloud right now, and what would break if we ran a destroy or a rolling patch tonight?
In this article, you will get a decision framework for Terraform versus Ansible, see the anti-patterns that fail in production, and walk through a Terraform-to-Ansible handoff you can adapt to your own cloud layout. The code examples use AWS; the same pattern applies to Azure, GCP, or any provider with a Terraform provider and SSH-reachable hosts.
Note
What you need: Terraform CLI 1.9+, Ansible 2.16+ (ansible-core 2.16+), credentials for your cloud provider (the examples use AWS EC2/VPC), and basic familiarity with HCL and YAML playbooks.
Why teams pick the wrong tool
Teams default to the tool the first engineer knew. That is how Ansible ends up provisioning networks and Terraform ends up installing Nginx inside terraform apply. The symptom table below maps what you see in the repo to what a clean boundary looks like.
| Symptom | Likely cause | What good looks like |
|---|---|---|
ansible-playbook creates dozens of cloud resources | Ansible is doing Day 0 provisioning without state | Terraform owns the cloud API; state lives in a remote backend with locking |
terraform apply runs shell scripts on every instance change | Terraform provisioners are doing Day 1 config | Ansible or cloud-init configures software; Terraform stops at the instance profile |
| Nobody can safely tear down staging | Resources split across tools with no single source of truth | terraform destroy removes the cloud layer; Ansible has no orphaned provisioner modules |
| Patching requires replacing instances | OS changes were encoded in Terraform user-data only | Ansible handles Day 2 mutable updates in place |
The cost is not theoretical. Wrong-tool choices show up as failed destroys, surprise terraform plan diffs that want to recreate instances, and playbooks that cannot detect drift because there is no authoritative inventory of what Ansible itself provisioned.
How Terraform and Ansible differ
Both are Infrastructure as Code (IaC) tools. They solve different problems in the lifecycle.
| Dimension | Terraform | Ansible |
|---|---|---|
| Primary job | Provision and manage cloud resources via provider APIs | Configure software and OS state on reachable hosts |
| Language | HCL (declarative desired state) | YAML playbooks (task sequences, idempotent modules) |
| State | Persistent state file tracks resource IDs and attributes | No cloud inventory state; relies on inventory and facts |
| Execution | Plan → apply against APIs | Push over SSH/WinRM (or pull via ansible-pull) |
| Agent on target | None | None (agentless) |
| Strength | Create/update/destroy networks, compute, databases, IAM, load balancers, DNS | Packages, configs, services, users, rolling deploys, patching |
| Weak at | Mutable in-guest software state, fleet orchestration | Complex cloud dependency graphs, clean teardown at scale |
Day 0, Day 1, Day 2 is the useful framing for infrastructure lifecycle phases:
| Phase | Examples | Right tool |
|---|---|---|
| Day 0 | VPC/VNet, subnets, security groups, VMs, managed databases, IAM roles, load balancers | Terraform |
| Day 1 | OS hardening, install Docker, deploy app v1, create app user | Ansible, cloud-init, or CI/CD |
| Day 2+ | Security patches, config drift fixes, rolling app updates | Ansible (+ specialized tools at scale) |
Terraform declares what the cloud should look like. Ansible declares what should be true on each machine. When those boundaries blur, operational pain follows.
Containers and Kubernetes change the boundary. If workloads run in Kubernetes, Terraform typically provisions the cluster, node pools, and cloud IAM. Application state belongs in Helm charts or a GitOps controller (Argo CD, Flux), not Ansible playbooks. For Docker Compose on a single host, Ansible may still install Docker and drop compose files. For a fully GitOps-managed cluster, you may skip Ansible entirely on the workload layer.
What happens if you use only one tool?
Teams often commit to one tool to reduce toolchain sprawl. That works until the workload crosses layer boundaries.
-
Terraform only: Sensible for serverless stacks, managed databases, and Kubernetes platforms where GitOps handles workloads. You hit walls on fleet-wide patching, rolling application deploys without instance replacement, and in-guest config drift. Every Nginx config change becomes a user-data edit and an instance recreate. Production teams end up bolting on provisioners or external scripts, which recreates the hybrid problem without Ansible's ergonomics.
-
Ansible only: Sensible for brownfield fleets, network device configuration, and patch compliance on existing hosts. You hit walls on multi-resource cloud layouts with clean destroy, dependency graphs across network/subnet/database resources, and drift detection at the API layer. A playbook that created 40 resources cannot answer "what exists?" the way
terraform plancan. Production teams end up maintaining inventory spreadsheets alongside playbooks.
The wrong patterns teams ship first
Before the handoff pattern, here is what the broken version looks like.
-
Using Ansible as your cloud provisioner
Teams already fluent in YAML reach for cloud collection tasks (
amazon.aws,azure.azcollection,google.cloud). This works for a handful of resources. It falls apart when you need destroy semantics, dependency ordering, or drift visibility.The playbook below provisions a small AWS stack entirely through Ansible. It is an anti-pattern for illustration: do not use this for production cloud stacks.
playbooks/provision-vpc-naive.yml - name: Provision app stack with Ansible only hosts: localhost connection: local tasks: - name: Create VPC amazon.aws.ec2_vpc_net: name: app-vpc cidr_block: 10.0.0.0/16 region: us-east-1 - name: Launch application instance amazon.aws.ec2_instance: name: app-server-01 instance_type: t3.small image_id: ami-0c55b159cbfafe1f0 vpc_subnet_id: "{{ subnet_id }}" region: us-east-1There is no equivalent to
terraform planshowing what will change. There is no first-classdestroythat walks dependencies in reverse. Re-running the playbook after manual console edits does not reliably converge the account back to a declared desired state. For multi-resource cloud layouts, that is the wrong tool. -
Using Terraform provisioners for application configuration
The mirror mistake is configuring software inside
terraform apply. This AWS example ties Nginx installation to the EC2 resource lifecycle:ec2-with-provisioner-naive.tf resource "aws_instance" "app" { ami = var.ami_id instance_type = "t3.small" subnet_id = aws_subnet.app.id provisioner "remote-exec" { inline = [ "sudo apt-get update", "sudo apt-get install -y nginx", "sudo systemctl enable nginx", ] } }Every instance replacement re-runs installation. Application deploys become Terraform operations. Secrets and scripts end up in state or in-line in HCL. Rolling updates across a fleet do not belong here. Terraform should output instance IDs and connection info; configuration belongs elsewhere.
A practical decision framework
Answer these in order. First match wins.
- Are you creating or destroying cloud resources through a provider API? (VMs, VPCs/VNets, managed databases, object storage, Kubernetes CRDs via a provider) → Terraform
- Are you changing software, files, packages, or services on hosts that already exist? → Ansible
- Is it bootstrap-only on first boot? (hostname, admin user, install agent) → cloud-init / user-data, optionally a short Ansible play triggered once
- Is it an application artifact? (container image, serverless zip, static build) → CI/CD pipeline, not Terraform or Ansible long term
- Is everything running on Kubernetes with GitOps? → Terraform for cluster and cloud IAM; Helm/Argo CD for workloads instead of Ansible
| You are automating | Use |
|---|---|
| Object storage + CDN distribution | Terraform |
| CIS hardening on 200 Linux VMs | Ansible |
| Managed database + subnet group + parameter group | Terraform |
| Rolling Node.js deploy with health checks | Ansible or CI/CD |
| IAM role for GitHub Actions OIDC | Terraform (see OIDC guide) |
| Quarterly OpenSSL package updates | Ansible |
| Multi-region DNS failover routing | Terraform (see failover guide) |
Tip
If you need both infrastructure and configuration for bare VMs, default to Terraform first, Ansible second. Provision stable connection endpoints, then configure. Do not run them in parallel against the same concern.
How to hand off from Terraform to Ansible
The production pattern is sequential: Terraform creates infrastructure and exports what Ansible needs. Ansible never hard-codes IP addresses that change on rebuild.
Wire the handoff in this order:
Step 1: Tag instances and export metadata from Terraform
Tag compute instances so Ansible can discover them without parsing state by hand. The resource block below adds standard tags to an AWS EC2 instance; use your provider's equivalent tag mechanism for Azure or GCP.
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "t3.small"
subnet_id = aws_subnet.app.id
tags = {
Role = "app"
Environment = var.environment
ManagedBy = "terraform"
}
}Export instance IDs and connection metadata as Terraform outputs so CI pipelines and operators can verify the handoff without digging through state files.
output "app_instance_ids" {
description = "EC2 instance IDs for Ansible dynamic inventory"
value = [aws_instance.app.id]
}
output "app_private_ips" {
description = "Private IPs for SSH bastion access"
value = [aws_instance.app.private_ip]
}
output "ansible_ssh_user" {
value = "ubuntu"
}Store state remotely (S3 + DynamoDB lock, Terraform Cloud, Azure Blob, GCS, or equivalent) so CI and operators share one source of truth.
Step 2: Point Ansible at your instances
Two inventory patterns are common in production. Pick one; do not maintain both for the same hosts.
Option A: Cloud provider dynamic inventory (most common)
The cloud provider's dynamic inventory plugin discovers instances by tags or labels. On AWS, the amazon.aws.aws_ec2 plugin is the standard choice when Terraform owns provisioning and Ansible only needs to find running hosts.
The inventory file below filters AWS instances by Role and Environment tags and builds host groups Ansible can target directly.
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:Role: app
tag:Environment: production
hostnames:
- tag:Name
- private-ip-address
compose:
ansible_host: private_ip_address
keyed_groups:
- key: tags
prefix: tagOption B: Terraform state inventory
The cloud.terraform inventory plugin reads Terraform state directly. Use this when your pipeline already downloads state after terraform apply and you want inventory derived from the same source of truth.
Point the plugin at your local or pulled state file with a minimal inventory configuration like this:
plugin: cloud.terraform.terraform_provider
terraform_state:
path: ./terraform.tfstateFor remote state, point path at the pulled state file your pipeline downloads after terraform apply, or use the plugin's supported remote backend configuration from the collection documentation.
Step 3: Configure software with Ansible
Once inventory is wired, Ansible should only manage software and configuration on hosts Terraform already provisioned. The playbook below installs Nginx and deploys an application config template:
- name: Configure application servers
hosts: tag_Role_app
become: true
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Deploy application config
ansible.builtin.template:
src: templates/app.conf.j2
dest: /etc/nginx/conf.d/app.conf
mode: "0644"
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloadedWhen using Option B (cloud.terraform), target the host group the plugin generates from your resource address (for example, aws_instance.app) instead of tag_Role_app.
Run Terraform apply first, then point Ansible at the dynamic inventory. The CI sequence below installs the required collections and runs the configuration playbook:
terraform init && terraform apply -auto-approve
ansible-galaxy collection install amazon.aws cloud.terraform
ansible-playbook -i inventory.aws_ec2.yml playbooks/configure-app.ymlTerraform owns the compute instance lifecycle. Ansible owns Nginx and app config. Destroy path stays clean: remove Ansible from the picture, terraform destroy deletes the cloud layer.
How to verify the split is working
Confirm the boundary with three checks after your first integrated apply.
- Terraform plan is empty of shell provisioners. Run
terraform planafter Ansible configures software. You should see no changes unless infrastructure inputs changed. If Nginx edits appear in plan, configuration leaked into Terraform. - Ansible
--checkconverges without cloud creation tasks. Runansible-playbook --checkagainst the dynamic inventory. Tasks should report changes only on packages, files, or services. No cloud provisioning modules should run in steady-state configuration playbooks. - Destroy is predictable. In a sandbox account,
terraform destroyremoves the cloud stack. Ansible should not leave orphaned resources you cannot reconcile because it was the provisioner of record.
| Check | Pass signal | Fail signal |
|---|---|---|
| Plan after config change | No changes from Terraform | Instance wants replacement because user-data changed |
| Ansible check mode | Task skips or shows service/file diffs only | Playbook tries to create VPC or VM |
| Staging destroy | All Terraform-managed IDs gone | Orphan instances remain in console |
When this approach breaks down
The Terraform-plus-Ansible split is not universal.
- Fully managed services: Serverless functions, container platforms without SSH access, and managed databases may need little or no Ansible. Pushing Ansible into those paths adds SSH assumptions that do not exist.
- Immutable infrastructure: If you bake VM images or container images with Packer and redeploy on every change, Ansible Day 2 plays shrink. Terraform (or Kubernetes) handles rollouts; patching moves into image rebuilds.
- Kubernetes-native platforms: GitOps controllers apply manifests continuously. Ansible OS hardening on nodes may still exist, but application state belongs in Helm charts, not playbooks.
- Network appliances and SaaS APIs: Ansible configures some firewalls and switches via API modules. Terraform manages others via providers. Pick per vendor capability, not habit.
- Small single-server stacks: A one-box staging environment might use cloud-init only. Adding Ansible and Terraform for one small VM is overhead without payoff.
Container-heavy teams should also read Docker ports vs expose for network boundary decisions that often sit next to this toolchain split.
For authoritative references, see the Terraform AWS provider documentation, the Ansible AWS guide, and HashiCorp's provisioner guidance (provisioners are a last resort).

