Skip to content
Back to blog
8 min read

Terraform Makes the Box. Ansible Configures It.

Terraform Makes the Box. Ansible Configures It.

You have thirty lines of Terraform that stand up a droplet, and exactly one thing left to do: get nginx onto it.

There is a block that will do that. It is about four lines. It goes directly inside the resource you already wrote, it fires the moment the machine exists, and you will be finished in ninety seconds rather than standing up an entire second tool with its own inventory and its own vocabulary.

Take it, and you have just moved a job into the one place least equipped to do it.

Two jobs that look like one

Provisioning and configuration feel like the same activity because they happen back to back and both end with “a working server.” They are not the same activity, and the tools that do them well are built on opposite assumptions.

Terraform’s model is desired state over an API it can inspect. It reads what exists, compares it to what you declared, and shows you the difference before it touches anything. That preview is the entire product. terraform plan is why you trust terraform apply.

Ansible’s model is convergence over SSH on a machine that already exists. It connects to a box, checks whether each step has already been done, and does the ones that haven’t. It assumes a running operating system, a login, and a filesystem. None of which exist yet at the moment Terraform is deciding what to create.

The confusion is understandable. Both are “infrastructure as code,” both are declarative-ish, both live in version control. But one is talking to a cloud provider about resources, and the other is talking to an operating system about packages. Those are different conversations.

What Terraform actually says about this

Reach for a provisioner and you will find HashiCorp has already thought about it, in their own documentation, at some length. The current guidance is blunt:

Terraform is primarily designed for immutable infrastructure operations, so we strongly recommend using purpose-built solutions to perform post-apply operations.

And more pointedly, practitioners should “exhaust all alternatives before using provisioners in your configurations.”

The reason matters more than the warning, and it is one line:

Terraform cannot predictably model provisioner behaviors represented in the configuration.

Sit with that. Terraform’s value is that it tells you what will happen before it happens. A provisioner is the one thing inside your configuration that it cannot tell you anything about. It cannot plan it, cannot diff it, cannot detect that the script already ran, and cannot notice that someone changed the script’s behavior. It shows up in the plan as a black box that will execute some shell.

So the four-line shortcut does not just add a small piece of configuration management to your provisioning tool. It punches a hole in the thing that made the provisioning tool worth using. You now have a terraform plan that is honest about your network and silent about your software.

There is a second cost that shows up later. A provisioner needs network access to the machine and credentials to log into it, at apply time, from wherever apply is running. You have quietly made your CI runner a privileged SSH client on every box it creates, which is an odd thing to build on purpose right after going to the trouble of closing your public management ports.

The seam is a file

If the two tools should not call each other, they need a handoff. The handoff I use across the estate is deliberately boring: Terraform writes a file, and Ansible reads it.

Terraform is the only thing that knows the addresses, because Terraform is what created the machines. So it renders an inventory:

resource "local_file" "ansible_inventory" {
  content = templatefile("${path.module}/templates/inventory.yml.tpl", {
    management_name       = local.management_name
    management_ip         = module.management_node.ipv4_address
    management_private_ip = module.management_node.ipv4_address_private
    vpc_nodes             = data.digitalocean_droplets.vpc_nodes.droplets
  })
  filename = "${path.module}/../../../ansible/inventory/production.yml"
}

That is the entire integration. No glue script, no wrapper, no custom dynamic-inventory plugin, no third tool to orchestrate the first two. Terraform’s own templatefile function, rendering values that came out of its own state, into a YAML file that Ansible already knows how to read.

The template does one more thing worth noticing. Alongside the hosts, it writes out group variables:

  vars:
    management_host: ${management_name}
    prometheus_server: ${management_private_ip}
    loki_server: ${management_private_ip}

The Ansible layer needs to know where to ship metrics and logs. That address is a private VPC IP that did not exist until Terraform allocated it. Rather than teaching Ansible to go ask DigitalOcean, Terraform states the fact once, on its way out.

Why a file is the right shape

The seam being a plain file is not laziness. It is what keeps each layer independently useful.

The Ansible half of this repository does not import anything from Terraform. It does not hold a cloud API token. It does not know what a droplet is, or which provider made one. It reads a YAML file listing hostnames, addresses, and a few variables, and it configures whatever is on the other end of those addresses.

Which means it runs against machines Terraform never touched. There is a hand-editable manual.yml sitting next to the generated inventory for exactly that case, and the preflight check accepts either one:

pass  ansible/inventory/production.yml exists (Terraform-generated)
fail  No Ansible inventory found — run terraform apply
      (auto-generates production.yml) or populate manual.yml

That is the payoff of the boundary, stated as a passing check. Someone with three servers they built by hand two years ago can use the configuration layer without adopting the provisioning layer. Someone who already has Terraform gets the inventory for free. Neither half is hostage to the other, and each one can be reviewed, tested, and reasoned about on its own.

Try to get that property out of an architecture where Terraform shells into the box.

The temptation to cheat

The pressure to blur this is real, and it never arrives as “let’s make an architectural mistake.” It arrives as one small exception.

It’s one package. It’s a single systemd unit. It’s just a chmod on a file you already created. Each one is genuinely small, which is what makes the line hard to hold, because the argument for any individual exception is always stronger than the argument for the boundary.

The tell is what happens on the second run. Provisioners fire on resource creation, not on every apply. Change the script and nothing happens to a server that already exists. Your configuration now describes something your infrastructure isn’t, and Terraform will cheerfully report no changes, because as far as it is concerned there are none. The drift is invisible precisely because it lives in the part Terraform declines to model.

Ansible handles this case by design. Run it again and it converges. That is not a bonus feature, it is the whole reason the category exists.

Where the boundary moves

The rule is not “always use both.” It is “know which jobs your system actually has.”

One of my stacks provisions a managed Kubernetes cluster and has no Ansible at all. Not because I got lazy about it, but because there are no machines for me to configure. The managed control plane owns the nodes. The configuration job did not get merged into Terraform; it stopped existing, and the work moved up a layer into manifests and a GitOps controller.

That is the useful version of this principle. Ask what jobs exist, then pick the tool built for each one. Sometimes that is two tools. Sometimes it is one. It is never one tool doing two jobs badly because the second tool felt like overhead on a Thursday.

The honest weakness

A generated file has one failure mode, and pretending otherwise would be cheating: it can go stale.

Run terraform apply, add a node, forget to regenerate, and your inventory now describes an infrastructure that has moved on without it. Nothing errors. Ansible configures the hosts it was told about and is perfectly happy not knowing about the new one.

I have not solved this so much as made it loud. The preflight script emits a warning on every run:

warn  Verify production.yml has been regenerated after your latest
      terraform apply

An unconditional warning is a blunt instrument, and it is deliberately blunt. It fires whether or not anything is actually stale, because the check that would know for certain is more machinery than the problem deserves. The alternative designs all end with the two tools knowing about each other again, which is the thing the file was there to prevent.

That is a trade I will take. A seam I have to remember to refresh beats a coupling I can never remove.

The line

Terraform provisions infrastructure. Ansible configures machines. The two meet at a file, and neither one imports the other.

Hold that line and each layer stays reviewable on its own, testable on its own, and usable without the other. Blur it and you get a terraform plan that lies by omission, configuration drift that no tool reports, and a CI runner holding SSH credentials it never needed.

The four-line shortcut is still right there, in the resource you already wrote. It still takes ninety seconds.

It is still the most expensive ninety seconds in the file.