IaC

Terraform + Ansible Together: Provision with One, Configure with the Other

Teach me Ansible | 2026-08-12 | 21 min read

"Terraform vs Ansible" is one of the most searched — and most misleading — comparisons in DevOps. The truth is they aren't competitors. Terraform provisions infrastructure; Ansible configures it. Learn the handoff patterns that let each tool do what it does best, with complete working examples.

The "vs" Framing Is Wrong

Terraform and Ansible solve different problems. Terraform is a provisioning tool: it talks to cloud APIs to create, modify, and destroy infrastructure — VPCs, subnets, security groups, instances, load balancers, DNS records. It maintains a state file describing what exists, and its declarative model excels at answering "what infrastructure should exist?"

Ansible is a configuration management tool: it connects to machines that already exist (usually over SSH or WinRM) and brings them to a desired state — packages installed, config files templated, services running, users created. Yes, Ansible has cloud modules and can create an EC2 instance, and yes, Terraform has provisioners that can run scripts. But each is mediocre at the other's core job:

  • Terraform has no idempotent, declarative model for what happens inside a server. Its provisioners are procedural, run-once, and famously described by HashiCorp themselves as a "last resort."
  • Ansible has no dependency graph or plan/apply lifecycle for infrastructure. Creating 40 interrelated cloud resources with ordering, references, and teardown is painful without state.

The winning architecture: Terraform builds the house, Ansible furnishes it. The rest of this post covers the four handoff patterns for wiring them together, and when to use each.

The Handoff Problem

Once Terraform creates your instances, Ansible needs to know three things:

  1. Where are the hosts? (IP addresses / DNS names)
  2. What role does each host play? (web, db, worker...)
  3. When are they ready? (SSH up, cloud-init finished)

Every integration pattern is really just a different answer to those three questions.

Pattern 1: Terraform Outputs → Ansible Inventory

The simplest robust pattern: have Terraform export host information as outputs, then convert terraform output -json into an Ansible inventory.

Terraform Side

# outputs.tf
output "web_servers" {
  value = aws_instance.web[*].public_ip
}

output "db_servers" {
  value = aws_instance.db[*].public_ip
}

Dynamic Inventory Script

A dynamic inventory is any executable that prints JSON in Ansible's inventory format when called with --list. This small Python script reads Terraform outputs directly:

#!/usr/bin/env python3
# terraform_inventory.py
import json
import subprocess
import sys

def main():
    tf = json.loads(subprocess.check_output(
        ["terraform", "output", "-json"],
        cwd="../terraform"
    ))

    inventory = {
        "web": {"hosts": tf["web_servers"]["value"]},
        "db": {"hosts": tf["db_servers"]["value"]},
        "_meta": {"hostvars": {}},
        "all": {
            "vars": {
                "ansible_user": "ubuntu",
                "ansible_ssh_private_key_file": "~/.ssh/deploy_key"
            }
        }
    }
    print(json.dumps(inventory))

if __name__ == "__main__":
    if len(sys.argv) > 1 and sys.argv[1] == "--list":
        main()
    else:
        print(json.dumps({}))
chmod +x terraform_inventory.py

# Verify the inventory Ansible will see
ansible-inventory -i terraform_inventory.py --list

# Run your playbook against it
ansible-playbook -i terraform_inventory.py site.yml

You can also skip the script and generate a static inventory file from a Terraform template, but the dynamic script never goes stale — it always reflects the current state file.

Tip: The community cloud.terraform Ansible collection provides a ready-made terraform_provider inventory plugin that reads state files directly, so you don't have to maintain your own script.

Pattern 2: Terraform Provisioners Calling Ansible

Terraform can invoke ansible-playbook itself via a local-exec provisioner:

resource "aws_instance" "web" {
  ami           = var.ami_id
  instance_type = "t3.small"
  key_name      = var.key_name

  provisioner "local-exec" {
    command = <<-EOT
      until nc -z ${self.public_ip} 22; do sleep 5; done
      ansible-playbook -i '${self.public_ip},' \
        -u ubuntu --private-key ${var.private_key_path} \
        web.yml
    EOT
  }
}

(Note the trailing comma in -i '${self.public_ip},' — that's how you pass a literal ad-hoc host list to Ansible.)

Why You Should Usually Avoid This

It works for demos, but couples the tools in ways that hurt in production:

  • Provisioners run only at creation. Change a playbook later and Terraform won't re-run it — you'd have to taint and recreate the instance to reconfigure it.
  • Failures poison state. If the playbook fails, Terraform marks the resource tainted and wants to destroy/recreate it, even though the infrastructure itself is fine.
  • No retry granularity. You can't just re-run configuration; you re-run terraform apply.
  • Hidden dependencies. The machine running Terraform now needs Ansible, SSH keys, and network reachability — awkward in Terraform Cloud or remote runners.
  • HashiCorp's own docs call provisioners a last resort.

Prefer decoupling: let Terraform finish, then run Ansible as a separate step (patterns 1, 3, and 4). Reach for local-exec only for genuinely one-shot bootstrap tasks that belong to the resource's creation.

Pattern 3: Cloud Dynamic Inventory via Tags (Recommended)

The cleanest pattern removes the direct link entirely: Terraform sets tags; Ansible discovers hosts by querying the cloud API for those tags. Neither tool needs to know the other exists.

Terraform Sets the Tags

resource "aws_instance" "web" {
  count         = 2
  ami           = var.ami_id
  instance_type = "t3.small"

  tags = {
    Name        = "web-${count.index}"
    Role        = "web"
    Environment = "production"
    ManagedBy   = "ansible"
  }
}

Ansible Reads Them with the aws_ec2 Plugin

Create inventory/aws_ec2.yml (the filename must end in aws_ec2.yml or aws_ec2.yaml):

---
plugin: amazon.aws.aws_ec2
regions:
  - us-east-1

# Only pick up instances Terraform tagged for us
filters:
  tag:ManagedBy: ansible
  tag:Environment: production
  instance-state-name: running

# Build groups from the Role tag:
# Role=web -> group "role_web", Role=db -> group "role_db"
keyed_groups:
  - key: tags.Role
    prefix: role
  - key: tags.Environment
    prefix: env

# Connect via public DNS; prefer private IPs inside a VPC
hostnames:
  - dns-name
  - public-ip-address

compose:
  ansible_host: public_ip_address
  ansible_user: "'ubuntu'"
# Requires: pip install boto3, and AWS credentials in the environment
ansible-galaxy collection install amazon.aws

# Inspect the generated groups
ansible-inventory -i inventory/aws_ec2.yml --graph

# @all:
#   |--@role_web:
#   |  |--ec2-3-91-x-x.compute-1.amazonaws.com
#   |  |--ec2-54-160-x-x.compute-1.amazonaws.com
#   |--@role_db:
#   |  |--ec2-18-207-x-x.compute-1.amazonaws.com

Benefits: no state file sharing, no generated files to drift, instances created outside Terraform (autoscaling!) are picked up automatically, and the same inventory config works for every environment by changing one filter. Azure (azure.azcollection.azure_rm) and GCP (google.cloud.gcp_compute) have equivalent plugins.

Pattern 4: CI Pipeline Orchestration

In production, something has to run both tools in order. Let your CI/CD system be the conductor:

# .gitlab-ci.yml (the same shape works in GitHub Actions or Jenkins)
stages:
  - provision
  - configure

terraform_apply:
  stage: provision
  script:
    - cd terraform
    - terraform init -input=false
    - terraform plan -out=tfplan -input=false
    - terraform apply -input=false tfplan

ansible_configure:
  stage: configure
  needs: [terraform_apply]
  script:
    - pip install ansible boto3
    - ansible-galaxy collection install amazon.aws
    # Wait for SSH before configuring brand-new instances
    - ansible-playbook -i inventory/aws_ec2.yml wait_ready.yml
    - ansible-playbook -i inventory/aws_ec2.yml site.yml

The readiness gate matters — new instances need time for SSH and cloud-init:

# wait_ready.yml
---
- name: Wait for new instances to become reachable
  hosts: all
  gather_facts: false
  tasks:
    - name: Wait for SSH
      ansible.builtin.wait_for_connection:
        timeout: 300
        sleep: 10

    - name: Wait for cloud-init to finish
      ansible.builtin.command: cloud-init status --wait
      changed_when: false

This pattern gives you separate logs, separate retries (re-run only the Ansible job if configuration fails), approval gates between stages, and a clear audit trail.

Who Owns What: State and Drift

Clear ownership boundaries prevent the two tools fighting each other:

Terraform Owns

  • Infrastructure existence and shape — instances, networks, security groups, IAM, DNS, load balancers
  • Infrastructure drift detectionterraform plan shows when reality diverges from code (someone resized an instance in the console)
  • The state file — treat it as the source of truth for what exists

Ansible Owns

  • Everything inside the OS — packages, config files, services, users, certificates, application deployment
  • Configuration drift — run playbooks with --check --diff on a schedule to detect it, and re-run them to correct it (idempotence is the whole point)

The Golden Rule

Never let both tools manage the same attribute. If Terraform sets user_data that installs nginx, and Ansible also manages nginx, you have two sources of truth and eventual disagreement. Draw the line at the OS boundary: Terraform gets the machine to "booted with SSH available," Ansible takes it from there. Use Terraform's ignore_changes lifecycle argument for attributes something else manages (e.g. autoscaling desired counts).

Immutable vs Mutable: Does This Change the Answer?

The pairing looks different depending on your infrastructure philosophy:

  • Mutable infrastructure — long-lived servers that get updated in place. Terraform creates them once; Ansible runs repeatedly over their lifetime. The patterns above apply directly.
  • Immutable infrastructure — servers are never modified after boot; changes mean building a new image and replacing instances. Here Ansible moves to build time: use it as a Packer provisioner to bake AMIs, then Terraform deploys those AMIs and rolling-replaces instances. Ansible still does configuration — just once per image instead of per server.

Tradeoffs: immutable gives you perfectly reproducible servers, trivially safe rollbacks (redeploy the old AMI), and zero config drift — at the cost of slower iteration (rebuild an image for every change), image pipeline maintenance, and awkwardness for stateful services like databases. Mutable is faster to iterate and friendlier to stateful workloads, but you must actively police drift. Many teams mix both: immutable for stateless web/app tiers, mutable + Ansible for databases and legacy systems.

Complete Worked Example: 3 EC2 Instances, Tag-Driven Configuration

Let's wire pattern 3 end to end: Terraform creates two web servers and one database server with Role tags; Ansible configures each by group.

Step 1: Terraform Provisions

# main.tf
locals {
  instances = {
    web-0 = "web"
    web-1 = "web"
    db-0  = "db"
  }
}

resource "aws_instance" "servers" {
  for_each      = local.instances
  ami           = data.aws_ami.ubuntu.id
  instance_type = each.value == "db" ? "t3.medium" : "t3.small"
  key_name      = aws_key_pair.deploy.key_name
  vpc_security_group_ids = [aws_security_group.base.id]

  tags = {
    Name        = each.key
    Role        = each.value
    Environment = "production"
    ManagedBy   = "ansible"
  }
}
terraform init && terraform apply -auto-approve

Step 2: The Inventory (from Pattern 3)

The inventory/aws_ec2.yml file shown earlier produces role_web (2 hosts) and role_db (1 host) groups automatically.

Step 3: Ansible Configures by Group

# site.yml
---
- name: Baseline for every server
  hosts: role_web:role_db
  become: true
  tasks:
    - name: Apply security updates
      ansible.builtin.apt:
        upgrade: safe
        update_cache: true

    - name: Set hostname from the Name tag Terraform assigned
      ansible.builtin.hostname:
        name: "{{ tags.Name }}"

- name: Configure web servers
  hosts: role_web
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present

    - name: Deploy site config
      ansible.builtin.template:
        src: templates/site.conf.j2
        dest: /etc/nginx/sites-available/default
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

- name: Configure database server
  hosts: role_db
  become: true
  tasks:
    - name: Install PostgreSQL
      ansible.builtin.apt:
        name: postgresql
        state: present

    - name: Allow app subnet connections
      ansible.builtin.lineinfile:
        path: /etc/postgresql/14/main/pg_hba.conf
        line: "host all app 10.0.0.0/16 scram-sha-256"
      notify: Restart postgresql

  handlers:
    - name: Restart postgresql
      ansible.builtin.service:
        name: postgresql
        state: restarted
ansible-playbook -i inventory/aws_ec2.yml wait_ready.yml
ansible-playbook -i inventory/aws_ec2.yml site.yml

Scale out later by adding web-2 = "web" to the Terraform locals and re-running both commands — the new instance appears in role_web automatically and Ansible's idempotence leaves the existing hosts untouched.

Anti-Patterns to Avoid

  • Provisioning infrastructure with Ansible cloud modules at scale. Fine for a one-off VM; painful for interdependent stacks. No plan, no dependency graph, no state-driven teardown.
  • Configuring servers with Terraform provisioners. Run-once, taint-on-failure, no idempotence — the mirror-image mistake.
  • Committing generated inventory files. They go stale the moment infrastructure changes. Generate dynamically (patterns 1 and 3).
  • Parsing terraform.tfstate by hand. The state format is internal and changes between versions. Use terraform output -json or an inventory plugin instead.
  • Two owners for one attribute. user_data installing packages that Ansible also manages, or Ansible editing security-group-like host firewalls that Terraform owns. Pick one owner per layer.
  • No readiness gate. Running Ansible the instant apply returns fails randomly on slow boots. Always wait_for_connection and check cloud-init.
  • Secrets in tags or outputs. Tags are visible to anyone with describe permissions and state files store outputs in plaintext. Use Ansible Vault or a secrets manager.

Conclusion

Stop asking "Terraform or Ansible?" — the answer is both, with a clean seam between them:

  • Terraform provisions and owns everything up to a booted machine with SSH.
  • Ansible configures and owns everything inside the OS.
  • Tag-driven dynamic inventory (pattern 3) is the most robust handoff for cloud environments.
  • A CI pipeline (pattern 4) orchestrates the ordering, retries, and audit trail.
  • Avoid coupling them through provisioners except as a genuine last resort.

Get the boundary right and each tool's strengths compound: reproducible infrastructure from Terraform, idempotent drift-free configuration from Ansible, and a pipeline you can re-run any piece of with confidence.

Pro Tip

Standardize a small set of tags — Role, Environment, ManagedBy — across every Terraform module in your organization. With consistent tags, one aws_ec2.yml inventory config and one set of group-targeted playbooks work for every project, and new infrastructure becomes Ansible-manageable the moment it's created.