Terraform vs. Ansible: Not a Competition — A Partnership
One of the most common questions we hear from teams beginning their automation journey is: “Should we use Terraform or Ansible?” The answer, almost always, is both. They solve different problems, and they are strongest when used together.
In our previous post about the cost of not automating, we established why infrastructure automation matters. Now let us look at how — specifically, how Terraform and Ansible complement each other in a real-world workflow.
Different Tools, Different Jobs
The confusion comes from the fact that both Terraform and Ansible can, in theory, do many of the same things. Ansible can create cloud resources. Terraform can run scripts on servers. But just because a tool can do something does not mean it should.
Terraform excels at provisioning — creating, modifying, and destroying infrastructure resources. It maintains a state file that maps your declared configuration to real-world resources, enabling it to compute precise diffs and execute changes safely.
Ansible excels at configuration management — installing packages, managing files, starting services, and enforcing the desired state of a running system. It is agentless, using SSH to connect to targets, and its playbook model is intuitive for operations teams.
Here is a mental model:
- Terraform answers: “What infrastructure exists?”
- Ansible answers: “How is that infrastructure configured?”
graph LR
A[Terraform] -->|Provisions| B[VMs / Networks / LBs]
B -->|Inventory| C[Ansible]
C -->|Configures| D[Packages / Services / Config]
D --> E[Ready to Serve]
F[Git Commit] -->|Triggers| G[CI Pipeline]
G --> A
G --> C
A Real Workflow: Terraform Provisions, Ansible Configures
Let us walk through a practical example. We need to deploy a web application stack: 3 application servers behind a load balancer, with a managed PostgreSQL database. The infrastructure runs on Hetzner Cloud (a common choice for European companies).
Step 1: Terraform Provisions the Infrastructure
# main.tf
terraform {
required_providers {
hcloud = {
source = "hetznercloud/hcloud"
version = "~> 1.45"
}
}
backend "s3" {
bucket = "robto-terraform-state"
key = "production/terraform.tfstate"
region = "eu-central-1"
}
}
resource "hcloud_network" "main" {
name = "production"
ip_range = "10.0.0.0/16"
}
resource "hcloud_network_subnet" "app" {
network_id = hcloud_network.main.id
type = "cloud"
network_zone = "eu-central"
ip_range = "10.0.1.0/24"
}
resource "hcloud_server" "app" {
count = 3
name = "app-${count.index + 1}"
server_type = "cx31"
image = "ubuntu-24.04"
location = "fsn1"
ssh_keys = [hcloud_ssh_key.deploy.id]
network {
network_id = hcloud_network.main.id
ip = "10.0.1.${count.index + 10}"
}
labels = {
role = "app"
environment = "production"
}
}
resource "hcloud_load_balancer" "web" {
name = "web-lb"
load_balancer_type = "lb11"
location = "fsn1"
}
resource "hcloud_load_balancer_target" "app" {
count = 3
type = "server"
load_balancer_id = hcloud_load_balancer.web.id
server_id = hcloud_server.app[count.index].id
}
After terraform apply, we have three servers, a private network, and a load balancer. Terraform tracks all of this in its state file, so it knows exactly what exists and can compute changes incrementally.
Step 2: Generate a Dynamic Ansible Inventory
The bridge between Terraform and Ansible is the inventory. We use Terraform outputs to generate it:
# outputs.tf
output "app_servers" {
value = [for s in hcloud_server.app : {
name = s.name
public_ip = s.ipv4_address
private_ip = one([for n in s.network : n.ip])
}]
}
A simple script or Terraform provisioner writes this to an Ansible inventory file:
# inventory/production.ini
[app]
app-1 ansible_host=159.69.x.x private_ip=10.0.1.10
app-2 ansible_host=159.69.x.y private_ip=10.0.1.11
app-3 ansible_host=159.69.x.z private_ip=10.0.1.12
[app:vars]
ansible_user=root
ansible_python_interpreter=/usr/bin/python3
Alternatively, many teams use a dynamic inventory plugin that queries the Hetzner API directly, filtering by labels:
# inventory/hcloud.yml
plugin: hetzner.hcloud.hcloud
token: "{{ lookup('env', 'HCLOUD_TOKEN') }}"
groups:
app: "'app' in labels.role"
Step 3: Ansible Configures the Servers
Now Ansible takes over to turn bare Ubuntu servers into configured application hosts:
# playbooks/app-servers.yml
- hosts: app
become: true
roles:
- role: base
tags: [base]
- role: hardening
tags: [security]
- role: monitoring
tags: [monitoring]
- role: app_runtime
tags: [app]
Each role handles a specific concern:
# roles/base/tasks/main.yml
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install base packages
ansible.builtin.apt:
name:
- curl
- htop
- vim
- unattended-upgrades
- fail2ban
- chrony
state: present
- name: Configure timezone
community.general.timezone:
name: Europe/Bratislava
# roles/app_runtime/tasks/main.yml
- name: Install Docker
ansible.builtin.include_role:
name: geerlingguy.docker
- name: Create application directory
ansible.builtin.file:
path: /opt/app
state: directory
owner: deploy
group: deploy
mode: "0755"
- name: Deploy application compose file
ansible.builtin.template:
src: docker-compose.yml.j2
dest: /opt/app/docker-compose.yml
owner: deploy
group: deploy
notify: restart application
Step 4: Tie It Together in CI/CD
A simple pipeline orchestrates both tools:
# .gitlab-ci.yml (or GitHub Actions equivalent)
stages:
- plan
- provision
- configure
terraform-plan:
stage: plan
script:
- terraform init
- terraform plan -out=tfplan
artifacts:
paths: [tfplan]
terraform-apply:
stage: provision
script:
- terraform apply tfplan
- terraform output -json > tf_output.json
when: manual
ansible-configure:
stage: configure
script:
- ansible-playbook -i inventory/hcloud.yml playbooks/app-servers.yml
needs: [terraform-apply]
Common Mistakes to Avoid
Using Terraform’s remote-exec for configuration. It is tempting to inline shell scripts in Terraform using remote-exec provisioners. Do not. They are not idempotent, they pollute your Terraform state with configuration concerns, and they are painful to debug. Let Ansible handle configuration.
Using Ansible to create cloud resources. Ansible has cloud modules, but it lacks a state file. It cannot compute diffs or safely destroy resources. Every ansible-playbook run queries the API to discover current state, which is slow and fragile at scale. Let Terraform handle provisioning.
Skipping the state backend. Terraform’s local state file is fine for experiments. For anything shared, use a remote backend (S3, GCS, Terraform Cloud). Without it, your team will eventually corrupt state by running concurrent applies.
Not using roles in Ansible. Putting everything in a single playbook file works for 10 tasks. At 100 tasks, it becomes unmaintainable. Structure your Ansible code into roles from the start.
When This Pattern Breaks Down
For Kubernetes-native environments, the Terraform + Ansible pattern shifts. Terraform still provisions the cluster (or manages the managed Kubernetes service), but inside the cluster, you typically use Helm charts and Kubernetes manifests instead of Ansible. In our upcoming post about Kubernetes in production, we discuss these patterns in detail.
For fully serverless architectures, Ansible’s role shrinks since there are no servers to configure. Terraform (or Pulumi, or CDK) handles most of the work.
The Takeaway
Terraform and Ansible are not competing for the same job. They are two halves of a complete infrastructure automation workflow:
- Terraform provisions and manages the lifecycle of infrastructure resources.
- Ansible configures and maintains the state of running systems.
- A CI/CD pipeline orchestrates both, triggered by git commits.
Stop debating which one to use. Use both, in their respective strengths, and your infrastructure will be more reproducible, auditable, and maintainable than either tool could achieve alone.
At robto, we design and implement Terraform + Ansible workflows tailored to our clients’ environments. Whether you are starting from scratch or untangling years of manual configuration, we can help.