Terraform + Ansible Together: Provision with One, Configure with the Other
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:
- Where are the hosts? (IP addresses / DNS names)
- What role does each host play? (web, db, worker...)
- 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.
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 detection —
terraform planshows 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 --diffon 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.tfstateby hand. The state format is internal and changes between versions. Useterraform output -jsonor 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
applyreturns fails randomly on slow boots. Alwayswait_for_connectionand 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.