How Terraform and Ansible Work Together: Provision, Then Configure
Most people ask 'Terraform or Ansible?' That's the wrong question. Here's why they belong in the same workflow, and how to wire them together.
If you’re new to infrastructure automation, you’ll quickly run into both Terraform and Ansible. A natural question follows: which one should I use?
That’s the wrong question. They solve different problems. Using one docesn’t replace the need for the other.
Let me clear up the confusion, and give you enough context on both tools so you know exactly where to go deeper after reading this.
Why Automate Infrastructure at All?
Before getting into the tools, it’s worth asking: why bother with any of this? Why not just click through the AWS console, SSH in, and run commands manually?
The short answer: manual steps don’t scale, and they don’t reproduce.
Imagine you set up a server manually. Six months later, you need a second identical server. You try to remember every step. You probably miss something. Now your two servers are slightly different, and bugs only appear on one of them.
Or imagine a new team member needs to set up the same environment on their laptop. You write a “setup guide” that’s already out of date by the time they read it.
Infrastructure as Code (IaC) solves this by treating your infrastructure the same way you treat application code: written down, version controlled, repeatable, and reviewable. When your infra lives in code:
- Anyone can reproduce the exact same environment from scratch
- Changes go through code review before they happen
- You can roll back a bad change by reverting a commit
- You have a history of every change ever made
Terraform and Ansible are the two most widely used tools in this space. They take different approaches because they solve different problems within IaC.
The Misconception
The confusion comes from a surface-level overlap: both tools involve writing code to automate infrastructure. But their mental models are fundamentally different.
- Terraform is declarative and infrastructure-oriented. You describe what should exist.
- Ansible is procedural and configuration-oriented. You describe what should happen.
That distinction matters more than it sounds, and we’ll come back to it throughout this post.
What Terraform Does (and Does Well)
Terraform is an infrastructure provisioning tool. You declare the desired end state of your cloud resources, and Terraform figures out how to get there.
resource "aws_instance" "web" {
ami = "ami-0c02fb55956c7d316"
instance_type = "t3.micro"
tags = {
Name = "web-server"
}
}
This block says: “I want an EC2 instance running this AMI, of this size, with this name.” Terraform reads this, talks to the AWS API, and makes it happen. You don’t write the API calls yourself, Terraform handles that through providers, which are plugins for each platform (AWS, GCP, Azure, DigitalOcean, and many more).
The State File
One of Terraform’s most important concepts is the state file (terraform.tfstate). This is a JSON file that Terraform maintains to track what it has already created. When you run Terraform again, it compares your configuration against the state file to figure out what needs to change.
For example: if you have 3 servers in your config and Terraform’s state says 3 already exist, running terraform apply does nothing. If you change the config to 4 servers, Terraform sees the diff and creates exactly one more. This is what makes Terraform safe to rerun.
The Three Core Commands
You’ll use three commands constantly:
terraform init # downloads the required providers (run once per project)
terraform plan # shows what Terraform WILL do, without doing it
terraform apply # actually makes the changes
terraform plan is your best friend when you’re learning. It shows you exactly what will be created, modified, or destroyed before anything happens. Always run it before apply.
Terraform excels at:
- Creating VMs, networks, subnets, security groups, DNS records, load balancers
- Managing cloud-managed services (RDS, S3, EKS, etc.)
- Tracking infrastructure state across environments
- Safely applying incremental changes (
terraform planshows you exactly what will change) - Tearing down entire environments cleanly (
terraform destroy)
Terraform’s limits: Once a server is running, Terraform’s job is largely done. It doesn’t SSH in and install Nginx. It doesn’t manage /etc/hosts. It doesn’t configure a systemd service. That’s not what it was built for.
What Ansible Does (and Does Well)
Ansible is a configuration management tool. It connects to machines over SSH and runs tasks against them—installing software, copying files, starting services, and more.
- name: Install and start Nginx
hosts: web_servers
become: true
tasks:
- name: Install nginx
apt:
name: nginx
state: present
- name: Copy config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
- name: Start nginx
service:
name: nginx
state: started
enabled: true
A few things to note about this:
hosts: web_serverstells Ansible which machines to run this on. These come from an inventory file, which is just a list of your servers (more on this below).become: truemeans “run as root” (equivalent tosudo).state: presentthis is Ansible’s approach to idempotency. Instead of saying “runapt install nginx”, you say “nginx should be present”. Ansible checks first if it’s already installed, it skips the task. This means you can safely run the same playbook multiple times without breaking anything.
No agent is needed on the target machine; just SSH access and Python installed. Ansible runs from your local machine (or a CI server) and connects remotely.
Inventory
The inventory is how Ansible knows which machines to connect to. The simplest form is a plain text file:
# inventory.ini
[web_servers]
192.168.1.10 ansible_user=ubuntu
192.168.1.11 ansible_user=ubuntu
[db_servers]
192.168.1.20 ansible_user=ubuntu
Groups ([web_servers], [db_servers]) let you run playbooks against a specific subset of your fleet. A playbook with hosts: web_servers only touches those two machines—the database server is untouched.
Idempotency
Idempotency is a word you’ll see often in Ansible docs. It just means: running the same operation multiple times produces the same result as running it once.
This is important because you’ll rerun playbooks frequently: to apply updates, to fix a drift, to onboard a new server. You don’t want to accidentally restart a service that’s already correctly configured, or install a package that’s already there. Ansible’s modules handle this automatically for the common cases.
Ansible excels at:
- Installing packages and managing services
- Pushing config files (with templating via Jinja2)
- Running commands across a fleet of servers
- Bootstrapping a fresh OS after provisioning
- Enforcing consistent configuration across machines
- One-off operational tasks (rolling restarts, log rotation, cert renewal)
Ansible’s limits: Ansible is not great at creating cloud infrastructure from scratch. It can do it (there are AWS modules) but you’d be fighting the tool. Ansible has no concept of a state file, so if someone manually changes a resource, Ansible doesn’t know. It re-runs tasks from the top every time.
How They Complement Each Other
Think of it as two phases:
Phase 1: Terraform provisions:
- Creates the VPC, subnets, security groups
- Spins up EC2 instances
- Creates the RDS database
- Outputs the private IPs and hostnames
Phase 2: Ansible configures:
- SSH into the new instances
- Installs the runtime (Java, Node, Python)
- Copies application configs (templated with env-specific values)
- Starts the application service
- Configures log rotation, monitoring agents, etc.
Terraform hands the infrastructure to Ansible. Ansible makes it usable.
Neither tool is trying to do the other’s job. That’s the design. Once you accept the split, choosing what goes where becomes natural.
A Concrete Example: Provisioning a Web Server
Here’s how a real workflow looks, end to end.
Step 1: Terraform creates the server
# main.tf
resource "aws_instance" "app" {
ami = var.ami_id
instance_type = "t3.small"
key_name = aws_key_pair.deployer.key_name
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.app.id]
tags = { Name = "app-server" }
}
output "app_public_ip" {
value = aws_instance.app.public_ip
}
output blocks expose values from your Terraform config so you can use them elsewhere, like passing the IP address to Ansible.
After terraform apply, you have a running EC2 instance with a public IP, but it’s a blank Ubuntu server. Nothing is installed yet.
Step 2: Pass Terraform outputs to Ansible
# Get the IP from Terraform output
APP_IP=$(terraform output -raw app_public_ip)
# Write a dynamic inventory file for Ansible
cat > inventory.ini <<EOF
[app_servers]
${APP_IP} ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/deployer.pem
EOF
This is the handoff point. Terraform gives you an IP; you write it into an inventory file so Ansible knows where to connect.
Step 3: Ansible configures the server
# playbook.yml
- name: Configure app server
hosts: app_servers
become: true
vars:
app_port: 8080
java_version: "21"
tasks:
- name: Update apt cache
apt:
update_cache: true
- name: Install Java
apt:
name: "openjdk-{{ java_version }}-jre-headless"
state: present
- name: Create app user
user:
name: appuser
system: true
shell: /bin/false
- name: Copy systemd service
template:
src: app.service.j2
dest: /etc/systemd/system/app.service
- name: Start and enable service
systemd:
name: app
state: started
enabled: true
daemon_reload: true
Notice {{ java_version }} in the apt task? That’s Jinja2 templating. Ansible replaces it with the value from vars at runtime. This lets you write one playbook that works across different environments, just by changing variable values.
ansible-playbook -i inventory.ini playbook.yml
Two tools, two clear responsibilities. Each doing what it does best.
When to Use What
| Task | Tool |
|---|---|
| Create a VPC, subnet, security group | Terraform |
| Spin up EC2 / DigitalOcean droplet / VM | Terraform |
| Create an RDS instance or S3 bucket | Terraform |
| Install Nginx, Java, or Node on a server | Ansible |
Push an updated nginx.conf | Ansible |
| Restart a service across 20 servers | Ansible |
| Rotate an SSL certificate | Ansible |
| Destroy a staging environment | Terraform |
| Apply a security patch fleet-wide | Ansible |
When in doubt: if it’s about what exists in the cloud, use Terraform. If it’s about what’s running inside a machine, use Ansible.
What About Terraform Provisioners?
Terraform does have a feature called provisioners. They can run shell scripts or Ansible playbooks after a resource is created. You’ll see examples like this:
provisioner "remote-exec" {
inline = ["sudo apt-get install -y nginx"]
}
The Terraform documentation itself says: “Provisioners are a last resort.”
They break idempotency, they fail silently in weird ways, and they tightly couple your provisioning and configuration logic. If the provisioner fails, Terraform marks the resource as “tainted” and will try to destroy and recreate it on the next run, which is rarely what you want.
Use provisioners only for the bare minimum needed to make a machine reachable by Ansible (like ensuring Python is installed). For everything else, keep the boundary clean and call Ansible separately.
My Setup for the Homelab
For my local homelab (QEMU/KVM VMs on bare metal), I use exactly this split:
- Terraform + libvirt provider creates VMs from a cloud-init template—defines how many CPUs, how much RAM, which network, which disk image.
- Ansible runs after provisioning, installs k3s, sets up the container runtime, configures firewall rules, and joins nodes to the cluster.
Neither tool could do the full job alone without becoming awkward. Together, the workflow is clean and fully reproducible. I can destroy the whole cluster and rebuild it from scratch in about 10 minutes.
Getting Started
If you want to try this yourself, here’s the minimum to get moving:
Install Terraform:
# macOS
brew tap hashicorp/tap && brew install hashicorp/tap/terraform
# Ubuntu/Debian
sudo apt-get install terraform
Install Ansible:
# macOS
brew install ansible
# Ubuntu/Debian
sudo apt install ansible
Start with Terraform’s official getting started guide, the AWS or local (with Docker provider) tutorials are good first steps. For Ansible, the official docs intro will walk you through your first playbook.
You don’t need cloud infrastructure to learn either tool. Terraform has a Docker provider that works entirely on your local machine. Ansible can target localhost so you can practice without any remote servers.
Summary
Stop thinking about Terraform vs. Ansible. Think about:
- Terraform = what infrastructure exists (create, modify, destroy cloud resources)
- Ansible = how that infrastructure is configured (install, configure, start things on servers)
They occupy different layers of the stack. Once you internalize that, the workflow becomes obvious: provision with Terraform, configure with Ansible, automate the handoff between them.
The tools aren’t competing. They’re collaborating.
Once you’re comfortable with both individually, the natural next step is combining them in a CI/CD pipeline, so every infrastructure change goes through code review, automated testing, and a consistent deploy process. But that’s a topic for another post.