All articles
DevOps

Terraform vs Ansible: When to Use Which

Learn how to choose between Terraform and Ansible for cloud provisioning, server configuration, and Day 2 operations using a production decision framework and handoff pattern.

Terraform vs Ansible: When to Use Which cover
13 min read

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.

SymptomLikely causeWhat good looks like
ansible-playbook creates dozens of cloud resourcesAnsible is doing Day 0 provisioning without stateTerraform owns the cloud API; state lives in a remote backend with locking
terraform apply runs shell scripts on every instance changeTerraform provisioners are doing Day 1 configAnsible or cloud-init configures software; Terraform stops at the instance profile
Nobody can safely tear down stagingResources split across tools with no single source of truthterraform destroy removes the cloud layer; Ansible has no orphaned provisioner modules
Patching requires replacing instancesOS changes were encoded in Terraform user-data onlyAnsible 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.

DimensionTerraformAnsible
Primary jobProvision and manage cloud resources via provider APIsConfigure software and OS state on reachable hosts
LanguageHCL (declarative desired state)YAML playbooks (task sequences, idempotent modules)
StatePersistent state file tracks resource IDs and attributesNo cloud inventory state; relies on inventory and facts
ExecutionPlan → apply against APIsPush over SSH/WinRM (or pull via ansible-pull)
Agent on targetNoneNone (agentless)
StrengthCreate/update/destroy networks, compute, databases, IAM, load balancers, DNSPackages, configs, services, users, rolling deploys, patching
Weak atMutable in-guest software state, fleet orchestrationComplex cloud dependency graphs, clean teardown at scale

Day 0, Day 1, Day 2 is the useful framing for infrastructure lifecycle phases:

PhaseExamplesRight tool
Day 0VPC/VNet, subnets, security groups, VMs, managed databases, IAM roles, load balancersTerraform
Day 1OS hardening, install Docker, deploy app v1, create app userAnsible, cloud-init, or CI/CD
Day 2+Security patches, config drift fixes, rolling app updatesAnsible (+ 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.

  1. 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.

  2. 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 plan can. 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.

  1. 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-1

    There is no equivalent to terraform plan showing what will change. There is no first-class destroy that 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.

  2. 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.

  1. Are you creating or destroying cloud resources through a provider API? (VMs, VPCs/VNets, managed databases, object storage, Kubernetes CRDs via a provider) → Terraform
  2. Are you changing software, files, packages, or services on hosts that already exist?Ansible
  3. Is it bootstrap-only on first boot? (hostname, admin user, install agent) → cloud-init / user-data, optionally a short Ansible play triggered once
  4. Is it an application artifact? (container image, serverless zip, static build) → CI/CD pipeline, not Terraform or Ansible long term
  5. Is everything running on Kubernetes with GitOps? → Terraform for cluster and cloud IAM; Helm/Argo CD for workloads instead of Ansible
You are automatingUse
Object storage + CDN distributionTerraform
CIS hardening on 200 Linux VMsAnsible
Managed database + subnet group + parameter groupTerraform
Rolling Node.js deploy with health checksAnsible or CI/CD
IAM role for GitHub Actions OIDCTerraform (see OIDC guide)
Quarterly OpenSSL package updatesAnsible
Multi-region DNS failover routingTerraform (see failover guide)
Decision flowchart: cloud API tasks go to Terraform, host configuration goes to Ansible, and full VM stacks use Terraform first then Ansible for Day 1 configuration

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.

main.tf
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.

outputs.tf
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.

inventory.aws_ec2.yml
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: tag

Option 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:

inventory.terraform.yml
plugin: cloud.terraform.terraform_provider
terraform_state:
  path: ./terraform.tfstate

For 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:

playbooks/configure-app.yml
- 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: reloaded

When 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.yml

Terraform 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.

  1. Terraform plan is empty of shell provisioners. Run terraform plan after Ansible configures software. You should see no changes unless infrastructure inputs changed. If Nginx edits appear in plan, configuration leaked into Terraform.
  2. Ansible --check converges without cloud creation tasks. Run ansible-playbook --check against the dynamic inventory. Tasks should report changes only on packages, files, or services. No cloud provisioning modules should run in steady-state configuration playbooks.
  3. Destroy is predictable. In a sandbox account, terraform destroy removes the cloud stack. Ansible should not leave orphaned resources you cannot reconcile because it was the provisioner of record.
CheckPass signalFail signal
Plan after config changeNo changes from TerraformInstance wants replacement because user-data changed
Ansible check modeTask skips or shows service/file diffs onlyPlaybook tries to create VPC or VM
Staging destroyAll Terraform-managed IDs goneOrphan 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).

Frequently asked questions

Share𝕏

Writer

  • Ilyas Rufai

    Technical content writer and DevSecOps specialist focused on cloud-native security and developer experience

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
Terraform vs Ansible: When to Use Which | Reclear