Ansible and Terraform are not competitors, and treating them as one costs you
Terraform creates the machine, Ansible decides what is on it. Where the boundary belongs, why provisioners are a trap, and what changes when your estate is mostly containers.
"Ansible or Terraform" is a question we still get asked, and it has the same answer as "hammer or screwdriver". They solve different halves of the same job, and the interesting decision is where you draw the line between them.
The actual difference
Terraform is declarative and stateful: you describe the infrastructure you want, it diffs that against a state file, and it creates, changes or destroys resources to match. It knows what it created, so it can remove it.
Ansible is procedural and stateless: it connects to hosts and runs tasks, each of which is written to be idempotent. There is no state file, so it does not know what it did last time, and it cannot clean up after itself.
That difference decides everything. Anything whose lifecycle you need managed — created, changed, and crucially destroyed — belongs in Terraform. Anything that is a sequence of operations against a thing that already exists belongs in Ansible.
The boundary
Terraform: networks, subnets, security groups, load balancers, managed databases, IAM, DNS, Kubernetes clusters, object storage, the VM itself. If it has an API on the cloud provider, Terraform owns it.
Ansible: what happens inside the VM. Package installation, service configuration, users, certificates, application deployment onto instances, and one-off operational procedures across a fleet — patching, log collection, rotating a credential on 200 machines.
The seam is the moment the machine boots. Terraform hands over an IP and stops caring.
Do not use Terraform provisioners
remote-exec and local-exec are the most common way to get this wrong, and the Terraform documentation itself calls them a last resort.
The problem is that a provisioner runs exactly once, at create time, and its effects are invisible to state. If it fails halfway, the resource is marked tainted and your next apply destroys and recreates a machine because a package install had a transient network error. If you change the script, nothing happens to existing machines. If you need to re-run it, you destroy the instance.
The two correct patterns:
Bake the image. Packer builds an AMI or image with everything installed, and Terraform references it. Boot time drops to seconds, the machine is identical every time, and the image has a version you can roll back to. This is the right answer for anything that autoscales — you cannot have a new instance spending four minutes configuring itself during a traffic spike.
Configure after, out of band. Terraform outputs the inventory, Ansible runs against it as a separate pipeline stage:
terraform output -json instances | \
jq -r '.value[] | "\(.name) ansible_host=\(.private_ip)"' > inventory
ansible-playbook -i inventory site.yml
Or use the dynamic inventory plugin so Ansible queries the cloud provider by tag and no file is passed at all:
# inventory_aws_ec2.yml
plugin: amazon.aws.aws_ec2
regions: [eu-west-1]
filters:
tag:Role: app
instance-state-name: running
keyed_groups:
- key: tags.Environment
cloud-init in user_data sits between the two: fine for a handful of lines (install the agent, join the config management system), painful as a place to keep real configuration, because debugging it means reading logs on a machine that may not have booted.
What changes with containers
If your workloads are containers on Kubernetes, most of what Ansible used to do has moved into the image build and the manifest. The image is the configuration, kubectl or Argo CD is the deployment, and there is no machine to configure because the nodes are managed by a node group you never log into.
Ansible does not disappear, it shrinks to: the estate that is not containerised (and there is always some), node-level customisation that a managed node group cannot express, network appliances and on-premises hardware, and operational runbooks. That last category is underrated — a playbook is a far better incident runbook than a wiki page, because it is executable, reviewable and leaves a log.
Running both in CI
Terraform needs remote state with locking, plan on PR and apply on merge. Ansible needs less ceremony but benefits from the same discipline: run it from CI, not from laptops, with --check --diff on a pull request as the equivalent of a plan.
Give each one its own credentials. The Terraform role can create and destroy infrastructure; the Ansible role should only be able to connect to instances and read the secrets it distributes. They are different blast radii and there is no reason to merge them.