SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Ansible vs Terraform: which do you need?

Terraform creates the VPS, Ansible configures it. The real split, why provisioners fight both tools, the handoff commands, and when you need only Ansible.

Ansible vs Terraform in one sentence

Ansible vs Terraform is not a choice between two tools that do the same job. Terraform declares what infrastructure exists: servers, disks, networks, DNS records. Ansible declares what is true inside a machine that already exists: packages, users, config files, running services. Terraform creates the VPS. Ansible turns that VPS into a web server.

Both are declarative, and both are called infrastructure as code (IaC). The real difference is what they remember. Terraform writes a state file that maps every resource in your code to a real object it created through an API, so it can tell that deleting five lines means one server must be destroyed. Ansible remembers nothing between runs. It connects over SSH, inspects the machine, and changes only what does not already match the playbook.

That one difference explains the rest of this guide, including why mixing the two jobs into one tool goes wrong.

What Terraform actually does

Terraform talks to an API through a provider plugin. Your provider's registry page defines the resource types you may write, so a server on one host and a server on another are different resource names with different arguments.

resource "cloud_server" "web" {
  name  = "web1"
  image = "ubuntu-24.04"
  type  = "small"
}

output "web_ip" {
  value = cloud_server.web.ipv4_address
}

Replace cloud_server with the resource type your provider documents. The output block is the important part for this guide, because it is how the address leaves Terraform.

terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplan
terraform apply tfplan

terraform init downloads the provider and writes a lock file. terraform plan prints the difference between your code and the state file, ending in a line like Plan: 1 to add, 0 to change, 0 to destroy. Read that line every time. Some arguments cannot be changed in place, and the plan says so with # forces replacement next to the attribute, followed by 1 to add, 0 to change, 1 to destroy. Applying that plan deletes the server and builds a new empty one, which is how people lose data they thought was safe.

Saving the plan to a file and applying the file, rather than running a bare terraform apply, means the thing you reviewed is the thing that runs. Between the two commands, someone else may have changed the infrastructure.

terraform.tfstate is the memory. Lose it and Terraform no longer knows those servers are yours, so the next apply tries to create duplicates. Keep it in a remote backend as soon as more than one person runs the commands, because two people applying at once produces this:

Error: Error acquiring the state lock

OpenTofu is a fork of Terraform with the same commands and the same file format. As of July 2026, everything in this guide works if you type tofu instead of terraform.

What Ansible actually does

Ansible needs no agent and no API. It opens an SSH connection, copies a small Python module to the target, runs it, and deletes it. Anything you can reach with SSH and a sudo password, Ansible can configure.

- name: Base web server
  hosts: web
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Ensure nginx is running at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true
ansible -i inventory.ini web -m ansible.builtin.ping
ansible-playbook -i inventory.ini site.yml --check --diff
ansible-playbook -i inventory.ini site.yml

The ping module proves SSH, Python and sudo before you start debugging a playbook. A healthy result is web1 | SUCCESS => {"ping": "pong"}. The --check --diff run is Ansible's closest thing to a plan: it reports what would change without changing it, though tasks that depend on earlier tasks can report wrongly in check mode, because the earlier change never actually happened.

Every run ends with a recap such as ok=6 changed=2 unreachable=0 failed=0. Run the same playbook twice. The second run should report changed=0. A task that reports changed on every run is not idempotent, and it is usually a command or shell task that should have been a real module. If this is new ground, start with a first Ansible playbook on a single VPS and grow it from there.

Where the two tools overlap, and where they fight

Terraform can run commands on a new server with the remote-exec provisioner. HashiCorp's own documentation calls provisioners a last resort. There are good reasons.

A provisioner runs only when the resource is created. Edit the script and nothing happens on the existing server, because from Terraform's point of view the resource already matches the code. Provisioner steps never appear in terraform plan, so your review shows no sign of them. If the script fails, Terraform marks the resource tainted, and the next apply destroys and rebuilds a server that was probably fine.

The failure is also badly timed. The provider reports the server as created the moment the API says so, while the operating system is still booting and sshd is not listening yet.

Error: remote-exec provisioner error
timeout - last error: dial tcp 203.0.113.10:22: connect: connection refused

Ansible has the opposite temptation. Cloud modules can create servers, and for a handful of machines that works. What you give up is the dependency graph and the state file. Ansible will happily create a resource, but delete the task from your playbook and the resource stays running and stays billed, because nothing recorded that it was ever yours.

The rule that comes out of this: let Terraform own objects that an API creates and destroys, and let Ansible own everything inside a booted operating system.

The handoff, worked

The handoff is a boundary, not an integration. Terraform finishes, publishes an address, and stops. Ansible starts from that address.

terraform apply -auto-approve
terraform output -raw web_ip
printf '[web]\n%s ansible_user=root\n' "$(terraform output -raw web_ip)" > inventory.ini
ansible -i inventory.ini web -m ansible.builtin.ping
ansible-playbook -i inventory.ini site.yml

terraform output -raw prints one value with no quotes and no JSON wrapper, which is what you want inside a shell substitution. For several servers, use terraform output -json and build the inventory from that, since -raw handles only a single string, number or boolean.

The ping step between the two tools is worth keeping. It separates "Terraform gave me the wrong address" from "my playbook has a bug", and those two problems look identical when the playbook is the first thing that ever touches the new box.

Reading Terraform state as an Ansible inventory

If you would rather not write an inventory file at all, the cloud.terraform collection reads the state directly.

ansible-galaxy collection install cloud.terraform

Write terraform.yml next to your playbook:

plugin: cloud.terraform.terraform_provider
project_path: /home/deploy/infra
ansible-inventory -i terraform.yml --graph
ansible-playbook -i terraform.yml site.yml

Two things to know before you rely on it. The plugin runs terraform show against project_path, so that directory must already be initialized or the plugin fails. It also does not invent hosts from your server resources: it reads ansible_host and ansible_group resources, which you declare in your Terraform code using the Ansible provider. Nothing appears in ansible-inventory --graph until you add them.

A plain generated inventory file is easier to debug and works with any provider. The plugin pays off once the inventory has grown past a handful of machines and hand editing starts producing typos, which is the same point at which managing several Linux servers from one control machine becomes a real workflow rather than a habit.

Do you actually need Terraform?

Most people reading this do not, at least not yet. Terraform earns its cost when creating and destroying infrastructure is itself a repeated task. If you ordered one VPS through a control panel and intend to keep it for two years, Terraform describes a thing that happens once, and adds a state file you must not lose.

Reach for Terraform when you rebuild environments often, when staging has to match production exactly, when several people change infrastructure and you want a reviewable plan before anything is deleted, or when what you manage goes beyond servers into DNS records, load balancers and firewall rules that live in a provider API.

Stay with Ansible alone when the servers are long lived and few, and when the daily question is "is this box configured correctly" rather than "does this box exist". A single playbook that hardens a fresh server covers the same ground as the first ten minutes on a new VPS, with the advantage that it runs the same way on the next server.

Learning order follows from that. Ansible pays back on the first server you own. Terraform pays back on the third environment you rebuild.

What breaks in the handoff

The server is not ready. Terraform succeeds, Ansible fails immediately.

fatal: [web1]: UNREACHABLE! => {"changed": false, "msg": "Failed to connect to the host via ssh: ssh: connect to host 203.0.113.10 port 22: Connection refused", "unreachable": true}

The API returned an address before sshd was listening. Wait for the port rather than adding a fixed sleep. Ansible has ansible.builtin.wait_for_connection for exactly this, run as the first task of the play.

The host key changed. You destroyed and recreated the server, and the new one answers on the same address with a new key.

Host key verification failed.

Remove the stale entry with ssh-keygen -R 203.0.113.10. This happens constantly once Terraform is doing the rebuilding, which is a good reason to keep rebuilds rare on machines that hold data.

Sudo fails. fatal: [web1]: FAILED! => {"msg": "Missing sudo password"} means become: true needs a password on that host. Either configure passwordless sudo for the deploy user, or pass --ask-become-pass.

Terraform wants to destroy something you did not touch. The plan shows changes you never wrote, which means real infrastructure drifted from the code, usually because somebody changed a setting in the provider's web panel. Run terraform plan -refresh-only to see that difference by itself, then decide whether the code or the live resource is wrong. Never apply a destructive plan you cannot explain line by line.

Ansible reports changed on every run. A shell task with no creates or when guard runs unconditionally. That is not a cosmetic problem, because it means you can no longer use changed=0 as the signal that a server is in the state you asked for.

FAQ

Can Terraform replace Ansible?

Not for configuration inside a server. Terraform can call scripts with the remote-exec provisioner, but those run only at resource creation, never appear in terraform plan, and taint the resource when they fail, which schedules a destroy and rebuild on the next apply. Terraform has no equivalent of a module that checks whether nginx is already installed and does nothing if it is. Use Terraform to create the machine, then hand off.

Can Ansible replace Terraform?

For a small number of long lived servers, yes. Ansible has cloud modules that create servers, and if you order two VPS instances and keep them, that is enough. What you lose is the state file and the dependency graph: remove a task from the playbook and the resource keeps running and keeps billing, because Ansible never recorded that it created it. Terraform would have planned a destroy.

Which one should I learn first?

Ansible, if you own servers today. It pays back on the first machine, needs nothing but SSH, and the skill applies to a server you ordered by hand. Terraform pays back later, when you rebuild environments repeatedly or manage provider resources beyond servers, such as DNS records and firewall rules.

How do I pass the new server IP from Terraform into Ansible?

Declare an output in your Terraform code, then read it after apply. terraform output -raw web_ip prints the bare value for a shell substitution, and terraform output -json gives you every output at once when there are several hosts. Write that into an inventory file, or install the cloud.terraform collection and point ansible-inventory -i terraform.yml --graph at the project directory.

Why does my playbook fail right after Terraform finishes?

The provider reports the server as created as soon as its API says so, while the operating system is still booting, so SSH is refused for the first seconds. The error is UNREACHABLE! with Connection refused. Make ansible.builtin.wait_for_connection the first task in the play instead of guessing a sleep duration, because boot time varies with the image and the plan.