← Back to blog
TerraformAnsibleIaC

Terraform vs. Ansible: Not a Competition — A Partnership

· 6 min read

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:

  1. Terraform provisions and manages the lifecycle of infrastructure resources.
  2. Ansible configures and maintains the state of running systems.
  3. 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.