
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 integration patterns between them are well established. What's less well documented is where those patterns break in practice — and 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.
The most fundamental distinction between Terraform and Ansible lies in their primary focus:
Terraform is predominantly an orchestration tool. Its core strength is provisioning and managing the lifecycle of infrastructure resources—creating, updating, and destroying virtual machines, networks, storage, and DNS entries across various cloud providers and on-premises systems. Terraform excels at defining the "what"—the desired state of infrastructure components and their interdependencies.
Ansible is primarily a configuration management tool. Its forte is automating the setup and maintenance of software and systems within provisioned infrastructure. This includes installing packages, configuring services, deploying applications, and ensuring systems adhere to specific configurations. Ansible excels at defining the "how"—the steps to bring a system to its desired configured state.
While some overlap exists (Terraform can perform basic configuration via provisioners, and Ansible can provision infrastructure via cloud modules), their architectures and design principles are optimized for these distinct roles.
Terraform employs a declarative approach. Users define the desired end state of infrastructure in HCL (HashiCorp Configuration Language). Terraform then analyzes this desired state against the current actual state (tracked in its state file) and determines the necessary actions (create, update, delete) to achieve it. The order of resource definitions is generally not significant, as Terraform builds a dependency graph to determine execution sequence.
Ansible utilizes a procedural (imperative) approach. Ansible Playbooks, written in YAML, consist of tasks executed in the order they are defined. Users specify explicit steps to reach desired configuration, providing direct control over execution flow.
Terraform is stateful. It maintains a state file (terraform.tfstate) that stores a representation of managed infrastructure, mapping resources defined in configuration to real-world objects. Terraform relies on this file to plan changes, track dependencies, and manage resource lifecycles.
Ansible is largely stateless. It does not maintain persistent records of configuration state between runs. Each playbook execution assesses current node state and performs actions to achieve the desired configuration. While Ansible modules aim for idempotency, the tool doesn't rely on stored state like 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:
Successfully combining Terraform and Ansible requires a well-defined integration strategy. Several patterns have emerged, each with 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. The pattern that survived was a local_file resource carrying the key as sensitive content with file_permission = "0400", materializing it at run time — or sidestepping runner-side SSH entirely and bootstrapping 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 provides direct integration, allowing Ansible to run immediately after resource creation.
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
}
depends_on = [aws_instance.example]
}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, though this requires careful access management.
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:
Terraform handles the "what" of infrastructure, while Ansible handles the "how" of configuration management. Used together with proper integration patterns, they enable organizations to achieve:
The key to success is choosing the right integration pattern for your specific needs—favoring loose coupling through dynamic inventory over tight coupling through provisioners, maintaining clear separation of concerns, implementing proper state and secrets management, and using higher-level orchestration platforms when operational complexity demands it. And when something does break, check the execution environment before the playbook: in our experience, a missing binary, a missing key file, or a mismatched Python on the runner explains most Terraform-Ansible integration failures.
For more information on orchestration alternatives and management platforms, see our guide on Ansible Tower and Automation Controller Alternatives.
