Terraform creates, Ansible configures
Florian’s point was an honest one and I share its first half: Terraform alone works very well as long as you stick to a 100% immutable model, where you throw in a cloud-init at provisioning time, recreate the VM whenever you need to change it and nothing ever gets modified in place. If your infrastructure lends itself to that model, perfect, look no further. The interesting question starts at the precise moment it no longer does.
Two tools, two responsibilities
We routinely conflate Infrastructure as Code and configuration management, because both live in versioned text files and we file them under the same mental heading of “automation”. They are, in fact, two distinct responsibilities.
Terraform, OpenTofu , Pulumi describe and reconcile resources: a VM, a network, a bucket, a firewall rule, a DNS record. Their domain is the existence and shape of the object at the provider, not what runs inside it. Terraform knows perfectly well that an instance must exist with a given type and a given disk, it keeps its state, it computes the diff, it recreates whatever drifted. What it does not want to know is which service listens on which port after boot, nor what state the config file is in on the three-hundredth run.
Ansible, Salt, Puppet answer exactly that question: the desired state of the system, idempotently, with an inventory, variables, reusable roles and reporting. Their domain is the inside of the machine, once it exists.
The formula fits on one line and it captures the essential: Terraform creates, Ansible configures and maintains.
The immutable model, where Terraform is enough
In a strictly immutable world the boundary nearly vanishes, which is exactly why “Terraform on its own” works so well when it works. You never configure a live machine: you build an image upstream (with Packer, or a pipeline that bakes the AMI), you deploy it and any change goes through a new image followed by a recreation. The VM becomes a disposable artifact, cloud-init does nothing more than minimal bootstrapping and the state of the system collapses into the state of the infrastructure Terraform already drives.
It is elegant, it is reproducible and when the architecture lends itself to it (stateless, horizontally scalable, short life cycles) there is no reason to add a configuration manager. I have seen entire fleets live on that model without a single ansible-playbook ever having to run. The trap is not the immutable model itself, it is believing you are still in it when you no longer are.
The symptom: remote-exec and inline shell
The moment you leave the immutable world is easy to date, because it has a signature. As soon as you need to modify something after the VM is created, manage services, push slightly complex templates, chain an application update, or simply avoid writing three hundred lines of cloud-init, Terraform becomes a real pain. And since it has to run somehow, out comes the remote-exec provisioner, you paste in some inline shell, you chain a couple of local-exec and you find yourself doing configuration management inside a tool that was never designed for it.
The outcome is well known: no idempotence (the provisioner only replays cleanly if you code the check yourself), no readable reporting, an execution tied to the resource life cycle rather than the state of the system and a Terraform state that no longer reflects the reality of the machine, since whatever happened in the remote-exec is opaque to it. You did not gain simplicity, you merely moved the complexity somewhere it becomes illegible and fragile.
The remote-exec, to my eye, is not a tool, it is a signal. When you start needing it, the boundary between creation and configuration has just been crossed and it is time to give each tool back its role.
Keeping each tool in its role
The clean split holds no surprises and that is the point. Terraform (or OpenTofu) provisions: it makes the VM, the network, the load balancer, the DNS record exist and it keeps that perimeter reconciled. Ansible takes over once the machine is reachable: it installs, configures, templates, updates and it stays replayable at will without breaking what is already in place. Each in its role and the hacks disappear on their own, because you no longer have to bend one tool into doing the other’s job.
The handoff deserves a pause, because it is where homegrown setups turn baroque: how does Ansible discover what Terraform just created? The artisanal answer is to have Terraform spit out an inventory file and it works, but it recouples the two tools you had just separated. I prefer to decouple cleanly and let the inventory build itself from a source of truth both tools consult, DNS for instance, which is what I cover in inventory from DNS . Terraform publishes to it, Ansible reads from it and neither depends on the other’s state.
That leaves the underlying question, the one you always end up asking: do I even need a configuration manager? If everything fits in immutable containers, the debate shifts, because the disposable unit is no longer the VM but the image and that is another topic I take up in Docker and Ansible . And before choosing the tool, it is worth remembering where this desired-state discipline comes from, long predating the fashion, which I discuss in Ansible’s ancestor : the need is old, only the names change.
Where to land
If your infrastructure is genuinely immutable, stateless, with images baked upstream and short life cycles, then Terraform alone is the right call and adding a configuration manager would be needless ceremony. The day you write your first remote-exec for anything beyond trivial bootstrapping, do not treat it as an implementation detail: it is the infrastructure telling you that you have left the immutable model and that it is time to let Terraform create and Ansible configure, each in its place, with no hack in between.