TerraformXXVI · Cloud and Platform OperationsProduction Terraform
Terraform + Ansible: The Hand-Off
What you'll learn
- Define the boundary between Terraform (provisioning) and Ansible (configuration)
- Call Ansible from Terraform via the local-exec provisioner on a terraform_data resource
- Generate Ansible inventory from Terraform outputs and consume it in a separate run
- Apply the production pattern of provisioning first, configuring second
- Recognise the anti-pattern of putting OS-level configuration into Terraform
Prerequisites
Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13
Terraform and Ansible are not competing tools. They are layered tools: Terraform creates the infrastructure, Ansible configures what runs on it. The two operations have different blast radii, different lifecycles, and different recovery procedures. The most common production mistake is to use Terraform for both. This lesson covers the boundary, the hand-off, and the production pattern that survives scale.
The two operations
Terraform
│
▼
Provision infrastructure
VMs, networks, disks, IAM, load balancers, DNS
│
▼
Operating systems are running, but empty
│
▼
Ansible
│
▼
Configure systems
packages, users, files, services, application deploys
│
▼
Systems are running the workload
The boundary is the API of the resource Terraform manages. For an
EC2 instance, the boundary is the SSH port, the instance metadata
service, and the user data. For a Kubernetes cluster, the boundary
is the kubeconfig and the API server. For an Azure VM, the
boundary is the same SSH port and the managed identity endpoint.
Everything outside that boundary — the OS, the packages, the files, the running services — is Ansible’s territory.
Why the boundary matters
Three reasons the boundary has to be clean:
- Different blast radius. A Terraform change edits the topology: a new VM, a new VPC, a deleted security group. An Ansible change edits the operating system: a new package, a changed config file, a restarted service. The recovery procedures are different. Topology changes can be rolled back by replacing the resource; OS changes need a re-apply of the playbook, not a state rollback.
- Different lifecycle. Terraform resources have a create, read, update, delete lifecycle that the state file tracks. Ansible playbooks are stateless. They run, they converge, they end. There is no “Ansible state” that records what the playbook did last time.
- Different test surface. Terraform is tested with
terraform plan, withterraform test, and with the cloud provider’s API. Ansible is tested withansible-lint, with Molecule, and with idempotency checks. The tests are not interchangeable.
The hand-off: inventory from outputs
The cleanest pattern is for Terraform to produce an Ansible inventory as a file, and for the CI pipeline to run Ansible as a separate stage against that inventory. Terraform does not know that Ansible exists; Ansible does not know that Terraform exists. The shared artefact is the inventory file.
resource "local_file" "ansible_inventory" {
filename = "${path.module}/inventory/hosts.yml"
content = yamlencode({
all = {
vars = {
ansible_user = "ubuntu"
ansible_ssh_private_key_file = "~/.ssh/id_acme_prod"
ansible_python_interpreter = "/usr/bin/python3"
}
children = {
web = {
hosts = {
for vm in module.compute.vms :
vm.name => {
ansible_host = vm.private_ip
role = "web"
env = vm.environment
}
}
}
db = {
hosts = {
for vm in module.db.vms :
vm.name => {
ansible_host = vm.private_ip
role = "db"
}
}
}
}
}
})
file_permission = "0600"
}
output "inventory_path" {
value = local_file.ansible_inventory.filename
}
The Ansible playbook picks up the inventory with -i:
# READ-ONLY: confirm the inventory exists
ansible-inventory -i inventory/hosts.yml --list
# CONFIGURATION: run the playbook
ansible-playbook \
-i inventory/hosts.yml \
--diff \
playbooks/site.yml
The two operations are decoupled. The Terraform apply produces the inventory. A separate CI stage runs Ansible. The Terraform apply can be re-run without re-running Ansible. The Ansible run can be re-run without touching Terraform. The blast radii are separate.
When to invoke Ansible from Terraform
There are two patterns that justify calling Ansible from inside a
Terraform configuration. Both use terraform_data with a
provisioner "local-exec" block.
Pattern 1: bootstrap on first creation
For a brand-new environment, the very first apply needs Ansible
to run before the system is usable. A terraform_data resource
with triggers_replace set on a hash of the inputs runs the
provisioner exactly once per change:
resource "terraform_data" "bootstrap" {
triggers_replace = {
inventory_sha = sha256(local_file.ansible_inventory.content)
playbook_sha = sha256(file("${path.module}/playbooks/bootstrap.yml"))
}
depends_on = [module.compute, local_file.ansible_inventory]
provisioner "local-exec" {
command = "ansible-playbook -i ${local_file.ansible_inventory.filename} ${path.module}/playbooks/bootstrap.yml"
environment = {
ANSIBLE_HOST_KEY_CHECKING = "False"
}
}
}
This is the only place a local-exec against an Ansible run is
defensible. The apply blocks until the bootstrap completes; if
the bootstrap fails, the apply fails; the triggers_replace
ensures the bootstrap re-runs if the inputs change.
Pattern 2: configuration drift on a schedule
For long-lived configuration that drifts between Terraform runs,
a terraform_data resource with a cron schedule can run
Ansible from outside the apply. But this is rarely the right
answer in production — it couples Terraform’s lifecycle to
Ansible’s, and Terraform’s state is the wrong place to record
“the configuration was applied at 03:00.” A scheduled CI job is
the right pattern, not a Terraform cron.
# NOT recommended for production
resource "terraform_data" "nightly_apply" {
triggers_replace = timestamp()
provisioner "local-exec" {
when = create
command = "ansible-playbook -i ${local_file.ansible_inventory.filename} ${path.module}/playbooks/site.yml"
}
}
The when = create runs only on creation. triggers_replace = timestamp() replaces the resource on every apply, which is not
what you want. Use CI for the schedule.
The Ansible-side pattern
Ansible does not know that Terraform exists. It consumes the inventory file. It runs against the hosts in the inventory. It is responsible for everything from the SSH port inward.
# playbooks/site.yml
---
- name: Configure web tier
hosts: web
become: true
roles:
- role: common
- role: nginx
- role: acme_app
vars:
app_env: "{{ env }}"
- name: Configure database tier
hosts: db
become: true
roles:
- role: common
- role: postgresql
The common role is shared. The nginx and acme_app roles are
web-specific. The postgresql role is db-specific. Each role is
tested with Molecule against a disposable VM. The playbook is
idempotent — running it twice produces no diff on the second run.
How to validate
# READ-ONLY: confirm Terraform produced a valid inventory
ansible-inventory -i inventory/hosts.yml --list
# CONFIGURATION: lint the Ansible code
ansible-lint playbooks/
# READ-ONLY: confirm the playbook is idempotent (dry run)
ansible-playbook -i inventory/hosts.yml --check playbooks/site.yml
# CONFIGURATION: run the playbook
ansible-playbook -i inventory/hosts.yml --diff playbooks/site.yml
# CONFIGURATION: re-run and confirm no diff
ansible-playbook -i inventory/hosts.yml --diff playbooks/site.yml
The --check flag runs Ansible in dry-run mode; it shows what
would change without making any change. The second --diff run
after a successful apply should produce no output — that is the
idempotency assertion.
Production failure modes
remote-execinaws_instancefor OS configuration. The provisioner runs every time the instance is recreated. There is no idempotency; the second run can corrupt the configuration that the first run installed. The VM is recreated; the OS state is lost; the next apply reinstalls everything. Fix: move to Ansible; remove theremote-exec.- Terraform state records OS-level facts. An operator adds a
remote-execthat installs a package and adds the package version as a tag. The state now records package versions. A legitimate OS upgrade changes the package version. The nextterraform planproposes to recreate the instance. Fix: do not put OS state in Terraform state. local-execagainst an inventory that is not yet written. Thelocal-execruns before the inventory file exists, or runs against an empty inventory. The apply hangs on SSH timeouts. Fix:depends_onon thelocal_fileand themodule.computethat produces the inventory data.- Inventory file left on the runner between branches. A CI
runner runs the
productionbranch’s apply, then thedevbranch’s apply. The inventory fromproductionis still on disk; Ansible reads the wrong file. Fix: write the inventory underpath.module(relative to the configuration), not/tmp; or pass the path explicitly through the CI environment. - Ansible and Terraform both managing the same resource. The
Ansible playbook creates
/etc/motdand the Terraform configuration writes auser_datacloud-init that also writes/etc/motd. The two operations conflict; the last one to run wins. Fix: pick one tool per resource; the boundary is the API. - Bootstrap
terraform_dataresource fails silently. Alocal-execreturns exit code 0 but the Ansible run did not complete. The apply succeeds. The infrastructure is provisioned but not configured. Fix: seton_failure = failand assert in the playbook that the bootstrap completed.
What to do in production
- Provision with Terraform. Configure with Ansible. The boundary is the API of the resource Terraform manages.
- Produce the Ansible inventory from Terraform outputs via the
local_fileprovider. Do not write it by hand. - Run Ansible from a separate CI stage against the inventory. Treat the playbook run as a separate change with its own blast radius and its own audit trail.
- Reserve
local-execfor the bootstrap case where the apply genuinely cannot complete without Ansible. Every other case belongs in CI. - Never put
remote-execprovisioners inside resource blocks. Use cloud-inituser_datafor the very first boot if you must bootstrap from Terraform; move to Ansible as soon as the SSH port is open.
Verification
After working through this lesson, confirm the following:
- You can describe the boundary between Terraform and Ansible in one sentence.
- You can write a
terraform_dataresource with alocal-execprovisioner that runs an Ansible playbook against a generated inventory. - You can name the production failure mode of a
remote-execprovisioner inside anaws_instanceblock. - You can name the three reasons the boundary must be clean: blast radius, lifecycle, and test surface.
Knowledge check · 7 questions
Q1. What is the correct boundary between Terraform and Ansible?
Q2. Which of the following are reasons the Terraform/Ansible boundary must be clean? (Select all that apply.)
Q3. A remote-exec provisioner inside an aws_instance block is the wrong place to install packages, because it is not idempotent, blocks the apply on SSH, and leaves no record of the OS in state.
Q4. What is the cleanest pattern for handing host information from Terraform to Ansible?
Q5. When is calling Ansible from inside a Terraform configuration defensible?
Q6. A team uses Terraform to provision an EC2 instance and a remote-exec provisioner to install nginx and write the application config. Six months later, an operator upgrades the application package by hand. The next terraform plan proposes to recreate the instance. What is the fix?
Q7. Why is the bootstrap terraform_data resource required to have a triggers_replace hash?
Passing score: 75%. Answers are checked in this browser.