Skip to main content
RunBook Academy

TerraformXXVI · Cloud and Platform OperationsProduction Terraform

Terraform + Ansible: The Hand-Off

Intermediate⏱ ~14 minbash

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

Not yet marked complete on this device.

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:

  1. 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.
  2. 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.
  3. Different test surface. Terraform is tested with terraform plan, with terraform test, and with the cloud provider’s API. Ansible is tested with ansible-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

  1. remote-exec in aws_instance for 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 the remote-exec.
  2. Terraform state records OS-level facts. An operator adds a remote-exec that 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 next terraform plan proposes to recreate the instance. Fix: do not put OS state in Terraform state.
  3. local-exec against an inventory that is not yet written. The local-exec runs before the inventory file exists, or runs against an empty inventory. The apply hangs on SSH timeouts. Fix: depends_on on the local_file and the module.compute that produces the inventory data.
  4. Inventory file left on the runner between branches. A CI runner runs the production branch’s apply, then the dev branch’s apply. The inventory from production is still on disk; Ansible reads the wrong file. Fix: write the inventory under path.module (relative to the configuration), not /tmp; or pass the path explicitly through the CI environment.
  5. Ansible and Terraform both managing the same resource. The Ansible playbook creates /etc/motd and the Terraform configuration writes a user_data cloud-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.
  6. Bootstrap terraform_data resource fails silently. A local-exec returns exit code 0 but the Ansible run did not complete. The apply succeeds. The infrastructure is provisioned but not configured. Fix: set on_failure = fail and 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_file provider. 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-exec for the bootstrap case where the apply genuinely cannot complete without Ansible. Every other case belongs in CI.
  • Never put remote-exec provisioners inside resource blocks. Use cloud-init user_data for 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_data resource with a local-exec provisioner that runs an Ansible playbook against a generated inventory.
  • You can name the production failure mode of a remote-exec provisioner inside an aws_instance block.
  • You can name the three reasons the boundary must be clean: blast radius, lifecycle, and test surface.

Knowledge check · 7 questions

  1. Q1. What is the correct boundary between Terraform and Ansible?

  2. Q2. Which of the following are reasons the Terraform/Ansible boundary must be clean? (Select all that apply.)

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

  4. Q4. What is the cleanest pattern for handing host information from Terraform to Ansible?

  5. Q5. When is calling Ansible from inside a Terraform configuration defensible?

  6. 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?

  7. Q7. Why is the bootstrap terraform_data resource required to have a triggers_replace hash?

Passing score: 75%. Answers are checked in this browser.