
Last Reviewed for Accuracy by Ryan Fee on May 1, 2026.
Terraform provisions infrastructure (VMs, networks, DNS, load balancers) and Ansible configures what Terraform created. The hard part of using them together is the handoff: how Ansible learns which hosts exist, where each tool actually executes, and what happens when the machine running your Terraform doesn't look like the laptop you tested on.
HashiCorp Terraform and Ansible (now part of Red Hat) cover different, sequential layers of the automation stack, and the patterns for connecting them are well established. What's less well documented is where those patterns break in practice. In Scalr's support queue, most of the breakage is environmental, not logical.
This guide covers when to use each tool, three integration patterns (provisioners, dynamic inventory from outputs, cloud inventory plugins), how to wire both into CI/CD pipelines, and the failure modes Scalr sees most often when teams move this setup from a workstation to a remote execution platform.
Terraform and Ansible split along what each one is built to do:
Terraform is mostly an orchestration tool. What it's good at is provisioning and managing the lifecycle of infrastructure resources: creating, updating, and destroying virtual machines, networks, storage, and DNS entries across cloud providers and on-premises systems. Terraform defines the "what," the desired state of infrastructure components and how they depend on each other.
Ansible is mostly a configuration management tool. What it's good at is automating the setup and maintenance of software and systems inside provisioned infrastructure. That means installing packages, configuring services, deploying applications, and making sure systems match the configuration you want. Ansible defines the "how," the steps to bring a system to its configured state.
There's some overlap (Terraform can do basic configuration through provisioners, and Ansible can provision infrastructure through cloud modules), but each tool's architecture is built for its own role.
Terraform is declarative. You define the end state of infrastructure you want in HCL (HashiCorp Configuration Language). Terraform compares that against the current actual state (tracked in its state file) and works out what it has to do (create, update, delete) to get there. The order you write resources in usually doesn't matter, because Terraform builds a dependency graph to figure out the execution sequence.
Ansible is procedural (imperative). Ansible playbooks, written in YAML, are tasks that run in the order you define them. You spell out the steps to reach the configuration you want, which gives you direct control over the flow.
Terraform is stateful. It keeps a state file (terraform.tfstate) that stores a representation of the infrastructure it manages, mapping resources in your configuration to real-world objects. Terraform leans on this file to plan changes, track dependencies, and manage resource lifecycles.
Ansible is largely stateless. It doesn't keep persistent records of configuration state between runs. Each time a playbook runs, it checks the current state of the node and does what it needs to reach the configuration you want. Ansible modules aim to be idempotent, but the tool doesn't rely on stored state the way Terraform does.
| Feature | Terraform | Ansible |
|---|---|---|
| Primary Use Case | Infrastructure Provisioning & Orchestration | Configuration Management & Deployment |
| Approach | Declarative | Procedural/Imperative |
| Language | HCL | YAML |
| State Management | Stateful (maintains tfstate file) | Largely Stateless |
| Infrastructure Type | Favors Immutable | Often used with Mutable |
| Resource Lifecycle | Strong (create, update, delete) | Limited (configuration focus) |
| Agent Requirement | Agentless (APIs) | Agentless (SSH/WinRM) |
| Dependency Handling | Builds resource graph | Executes tasks in order |
The core idea behind using Terraform and Ansible together is that they address different, sequential layers of the automation stack:
Terraform handles "Day 0" activities: initial provisioning and lifecycle management of infrastructure components like virtual machines, networks, and load balancers. It answers: "What infrastructure do I need, and where?"
Ansible handles "Day 1 and beyond" tasks: configuration of provisioned resources. This includes installing software, applying security policies, deploying application code, and managing ongoing system states. It answers: "Now that I have this infrastructure, how do I make it do what I need?"
This division of labor plays to each tool's strengths:
By integrating Terraform and Ansible, organizations can automate the entire service lifecycle:
A single Git push can take you from a bare cloud account to a running, configured application: Terraform stands up the servers, networks, and databases, then Ansible installs packages, applies security policies, and ships the application code. Because both layers are code, the same review and version-control workflow covers infrastructure and configuration, and a new engineer can read exactly how production was built.
The split also pays off when things go wrong. In a disaster-recovery scenario, Terraform reprovisions the infrastructure from state and Ansible reapplies configuration on top, so recovery is a pipeline run rather than a runbook. And for teams that prefer immutable infrastructure, Ansible fits the golden-image workflow: it bakes the configuration into an AMI or image once, and Terraform deploys that image instead of configuring live hosts.
To combine Terraform and Ansible well, you need a clear plan for how they connect. A few patterns have emerged, each with its own advantages and typical use cases.
How it works: The local-exec provisioner runs a command locally on the machine executing Terraform. It can invoke an Ansible playbook targeting newly created resources, passing IP addresses or other identifiers from Terraform to Ansible.
Example:
resource "aws_instance" "web" {
ami = "ami-0c55b31ad2c455b55"
instance_type = "t2.micro"
key_name = "your-ssh-key-pair"
tags = {
Name = "WebServer"
}
}
resource "null_resource" "wait_for_ssh" {
depends_on = [aws_instance.web]
provisioner "remote-exec" {
inline = ["echo 'SSH is up'"]
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/your-ssh-key.pem")
host = aws_instance.web.public_ip
}
}
}
resource "null_resource" "ansible_provision" {
depends_on = [null_resource.wait_for_ssh]
provisioner "local-exec" {
command = <<EOT
ansible-playbook \
-i "${aws_instance.web.public_ip}," \
--private-key ~/.ssh/your-ssh-key.pem \
-u ec2-user \
playbooks/configure-nginx.yml
EOT
}
}That private_key = file("~/.ssh/your-ssh-key.pem") line is where this pattern fails first once Terraform stops running on your workstation. A managed-services provider we worked with at Scalr hit it on their very first remote-execution run: a remote-exec block to install Python on a fresh Fedora VM, then local-exec running ansible-playbook -i '${self.public_ip},' --private-key .... Two problems surfaced that never appear locally. The private key file didn't exist on the runner's disk, and the obvious workaround (pasting the PEM into a Terraform variable) stripped the line breaks, so SSH authentication kept failing even after the variable was written back out to a file. What survived was a local_file resource carrying the key as sensitive content with file_permission = "0400", writing it out at run time. The other option is to skip runner-side SSH entirely and bootstrap the host with cloud-init or remote-exec from inside the VM. The rule to internalize: your laptop is not the runner.
Pros:
Cons:
The third con on that list deserves expansion, because it is the single most common way this pattern fails on hosted platforms. A consultancy wiring Ansible into their Terraform runs tested everything locally, pushed it, and watched every remote run die with exit status 127. Output: /bin/sh: 52: ansible-playbook: not found. Hosted run containers are minimal images; Ansible isn't in them. Their first fix attempt, building a custom runner image directly on the agent VM, hit a second wall: /opt/scalr-agent/embedded/bin/docker: No such file or directory. Their agent had been installed via Docker rather than RPM/DEB, which means task images are pulled from a registry, not built on the VM host. The same-day unblock was a pre-plan hook running python3 -m pip install --user ansible-core, applied environment-wide. The durable fix was a custom image built FROM scalr/runner:latest with pip3 install ansible, pushed to their own registry and referenced via SCALR_AGENT_CONTAINER_TASK_IMAGE_REGISTRY. Before baking Ansible into a runner image, check how your agent was installed: that detail determines where the image has to live.
How it works: Terraform provisions infrastructure and generates outputs (IP addresses, instance IDs, DNS names). Ansible reads these outputs to build its inventory, decoupling the two tools.
Terraform outputs:
output "web_server_ips" {
value = aws_instance.web[*].public_ip
}
output "web_server_ids" {
value = aws_instance.web[*].id
}Ansible inventory file (terraform_inventory.ini):
[web_servers]
web_server_1 ansible_host=<IP_from_terraform>
web_server_2 ansible_host=<IP_from_terraform>
[web_servers:vars]
ansible_user=ec2-user
ansible_ssh_private_key_file=~/.ssh/your-key.pemPros:
Cons:
How it works: Ansible uses built-in inventory plugins (aws_ec2, azure_rm, gcp_compute) that query cloud provider APIs to discover resources. Terraform applies specific tags (environment:prod, role:webserver) that the Ansible plugin uses to filter and group hosts.
Ansible inventory plugin example (aws_inventory.yml):
plugin: aws_ec2
regions:
- us-east-1
keyed_groups:
- key: 'tags.Environment'
prefix: env
- key: 'tags.Role'
prefix: role
filters:
tag:Provisioner: terraform
hostnames:
- ip-addressTerraform (applying tags):
resource "aws_instance" "web" {
ami = "ami-0c55b31ad2c455b55"
instance_type = "t2.micro"
tags = {
Name = "WebServer"
Environment = "production"
Role = "webserver"
Provisioner = "terraform"
}
}Pros:
Cons:
The Terraform Ansible provisioner wires the two together directly, so Ansible runs right after a resource is created.
resource "aws_instance" "example" {
ami = "ami-0c55b31ad2c455b55"
instance_type = "t2.micro"
provisioner "ansible" {
plays {
playbook {
file_path = "${path.module}/playbook.yml"
}
}
on_failure = continue # or fail
}
}resource "null_resource" "configure_web_servers" {
provisioner "local-exec" {
command = "ansible-playbook -i ansible/inventory.ini playbooks/web_setup.yml"
}
depends_on = [aws_instance.web]
}resource "null_resource" "run_playbook" {
provisioner "local-exec" {
command = "ansible-playbook -i inventory.ini playbooks/app_deploy.yml -e 'app_version=${var.app_version} environment=${var.environment}'"
}
}One trap when passing credentials this way: shell commands launched from Terraform inherit environment variables, not provider blocks. A provider configured only through an aliased block exports nothing to the subprocess, so an ansible-playbook call can silently fall back to the runner's machine identity.
Ansible can read Terraform state files to build inventory, but you have to manage access to that state carefully.
Using terraform_state plugin:
---
plugin: community.general.terraform_state
hostnames:
- private_ip
groups:
tag_Name: tags.NameAccessing remote state:
For remote backends (Terraform Cloud, S3), ensure Ansible has appropriate credentials and access controls.
A more secure approach uses Terraform to generate inventory files:
locals {
ansible_inventory = {
all = {
children = {
web_servers = {
hosts = {
for instance in aws_instance.web :
instance.tags["Name"] => {
ansible_host = instance.public_ip
ansible_user = "ec2-user"
}
}
}
}
}
}
}
resource "local_file" "inventory" {
content = yamlencode(local.ansible_inventory)
filename = "${path.module}/inventory.yml"
}Terraform outputs for Ansible:
output "database_endpoint" {
value = aws_db_instance.main.endpoint
description = "Database endpoint for Ansible configuration"
}
output "app_servers" {
value = {
for instance in aws_instance.app :
instance.tags["Name"] => instance.private_ip
}
}Ansible reading outputs (via local script):
#!/bin/bash
TERRAFORM_OUTPUTS=$(terraform output -json)
DB_ENDPOINT=$(echo $TERRAFORM_OUTPUTS | jq -r '.database_endpoint.value')Terraform reading Ansible facts:
resource "null_resource" "gather_facts" {
provisioner "local-exec" {
command = "ansible-playbook playbooks/gather_facts.yml --extra-vars 'output_file=/tmp/facts.json'"
}
}
locals {
ansible_facts = jsondecode(file("/tmp/facts.json"))
}GitLab CI/CD example:
stages:
- provision
- configure
- test
provision_infrastructure:
stage: provision
script:
- terraform init
- terraform plan -out=tfplan
- terraform apply tfplan
- terraform output -json > terraform_outputs.json
artifacts:
paths:
- terraform_outputs.json
- .terraform
configure_with_ansible:
stage: configure
dependencies:
- provision_infrastructure
script:
- ansible-playbook -i inventory.ini playbooks/app_setup.yml
only:
- main
smoke_tests:
stage: test
script:
- ansible-playbook playbooks/smoke_tests.ymlGitHub Actions example:
name: Infrastructure and Configuration
on: [push]
jobs:
provision:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: hashicorp/setup-terraform@v1
- run: terraform init
- run: terraform apply -auto-approve
- run: terraform output -json > outputs.json
- uses: actions/upload-artifact@v2
with:
name: terraform-outputs
path: outputs.json
configure:
needs: provision
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- uses: actions/download-artifact@v2
with:
name: terraform-outputs
- run: pip install ansible
- run: ansible-playbook -i inventory.ini playbooks/setup.ymlThat pip install ansible step gets less scrutiny than it should. Ansible is a Python program, so its behavior is a function of whatever interpreter the runner provides. In April 2026, a platform team in a regulated industry audited the managed runners in their pipeline and found Python 3.9.2 (end-of-life since October 31, 2025) paired with pip 20.3.4, while their self-hosted agents ran Python 3.13.11 with pip 25.3. Anything pip-installed at run time, Ansible included, resolved to different versions depending on where the run landed. If Ansible is part of your run, pin the runtime by owning the runner image rather than installing into whatever Python happens to be there.
infrastructure/
├── terraform/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── environments/
│ ├── dev/
│ ├── staging/
│ └── production/
└── ansible/
├── inventory/
│ ├── dev.ini
│ ├── staging.ini
│ └── production.ini
├── playbooks/
│ ├── common.yml
│ ├── app_deploy.yml
│ └── monitoring_setup.yml
└── roles/
├── web_server/
├── database/
└── monitoring/
For a deeper look at related tooling, see our guide to the top GitOps tools for 2025.
# Terraform manages infrastructure
resource "aws_autoscaling_group" "app" {
min_size = 2
max_size = 10
desired_capacity = 4
launch_template {
id = aws_launch_template.app.id
version = "$Latest"
}
}
# User data triggers configuration
resource "aws_launch_template" "app" {
user_data = base64encode(<<EOF
#!/bin/bash
# Wait for instance to be ready
sleep 30
# Configure with Ansible (golden image approach preferred)
# or trigger dynamic inventory update
EOF
)
}Preferred approach:
Avoid:
# Store Terraform state securely
terraform {
backend "s3" {
bucket = "terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}locals {
common_tags = {
Environment = var.environment
ManagedBy = "terraform"
Provisioner = "scalr" # or your management platform
Team = var.team
CostCenter = var.cost_center
}
}# Ansible playbooks should be idempotent
---
- name: Configure web servers
hosts: web_servers
gather_facts: yes
roles:
- common
- web_server
- monitoring
handlers:
- name: restart nginx
service:
name: nginx
state: restarted# Test playbook syntax
ansible-playbook playbooks/site.yml --syntax-check
# Dry run before applying
ansible-playbook playbooks/site.yml --check
# Validate with ansible-lint
ansible-lint playbooks/
# Test with Terraform
terraform validate
terraform plan -detailed-exitcode# Use Terraform Cloud/Enterprise for secrets
variable "database_password" {
sensitive = true
type = string
}# Use Ansible Vault for sensitive playbook data
ansible-vault create group_vars/databases/vault.yml
ansible-playbook playbooks/site.yml --ask-vault-pass# Keep Terraform state and Ansible playbooks in sync
# Document recovery procedures
# Test recovery regularly
# Maintain backups of state filesChallenge: Securely storing and managing Terraform state files across teams
Solution:
Challenge: Keeping Ansible inventory synchronized with Terraform-provisioned resources
Solution:
Challenge: Handling sensitive data (API keys, passwords, credentials) securely
Solution:
Challenge: Enabling multiple teams to work with Terraform and Ansible safely
Solution:
If you take one decision away from this guide, make it this: reach for dynamic inventory before you reach for a provisioner. A local_file resource that writes an inventory from Terraform outputs, or an aws_ec2 plugin that discovers hosts by the tags Terraform applied, keeps the two tools loosely coupled and survives the move from your laptop to a remote runner. A local-exec block that shells out to ansible-playbook mid-apply does the opposite: it ties an Ansible failure to your Terraform state and assumes the runner looks like your workstation.
When something does break, check the execution environment before you touch the playbook. Three of the cases in this guide, the missing ansible-playbook binary, the PEM key that wasn't on the runner's disk, and the end-of-life Python on a managed runner, were all environmental, and none of them reproduced locally. In Scalr's support queue that pattern holds: a missing binary, a missing key file, or a mismatched interpreter on the runner explains most Terraform-Ansible integration failures, far more often than the YAML.
For more information on orchestration alternatives and management platforms, see our guide on Ansible Tower and Automation Controller Alternatives.
