Using Terraform and Ansible Together
By the end of this lesson you will know three ways to hand servers from Terraform to Ansible, build a generated inventory file with Terraform's templatefile function, understand dynamic inventories that read the cloud directly, and know where to draw the line between the two tools.
The hand-over problem
Terraform creates servers and knows their IP addresses. Ansible configures servers but needs an inventory with those addresses. Something must carry the information from one tool to the other.
Think of a new housing society in Pune. The builder (Terraform) constructs the flats and hands the society a list of flat numbers and owners. The facility management company (Ansible) uses that list to set up water, electricity and security for each flat. If the list is wrong or old, the wrong flats get serviced.
Rule 1: decide who owns what
Before any wiring, agree on boundaries. A common split:
| Owned by Terraform | Owned by Ansible |
|---|---|
| Networks, subnets, firewalls (security groups) | Packages and OS updates |
| Virtual machines, disks, load balancers | Users, SSH settings, hardening |
| DNS records, certificates, buckets | App config files, services, deployments |
| Tags that describe each server's role | Anything inside the operating system |
Never let both tools manage the same setting. If Terraform sets a firewall rule and Ansible also edits it, each run undoes the other.
Option 1: Terraform writes the inventory file
Terraform can render an inventory file from its own data using templatefile. You can try this fully offline.
Step 1: Write an inventory template
In a new folder handover, create inventory.tftpl:
[web]
%{ for name, ip in web_servers ~}
${name} ansible_host=${ip}
%{ endfor ~}
[db]
%{ for name, ip in db_servers ~}
${name} ansible_host=${ip}
%{ endfor ~}
[all:vars]
ansible_user=ubuntuTerraform templates use ${ } for values and %{ } for directives such as for and if. The ~ removes the whitespace (including newlines) on that side of a directive, so the loop does not leave blank lines.
Step 2: Render it from Terraform
In a real project, the maps would come from resources, for example { for k, s in aws_instance.web : k => s.private_ip }. Here, variables stand in for the servers:
variable "web_servers" {
type = map(string)
default = { web1 = "10.0.1.10", web2 = "10.0.1.11" }
}
variable "db_servers" {
type = map(string)
default = { db1 = "10.0.2.10" }
}
resource "local_file" "inventory" {
filename = "${path.module}/inventory.ini"
file_permission = "0644"
content = templatefile("${path.module}/inventory.tftpl", {
web_servers = var.web_servers
db_servers = var.db_servers
})
}Step 3: Apply and check with Ansible
terraform init
terraform apply -auto-approve
cat inventory.ini[web] web1 ansible_host=10.0.1.10 web2 ansible_host=10.0.1.11 [db] db1 ansible_host=10.0.2.10 [all:vars] ansible_user=ubuntu
The blank lines from the template are gone: the ~ after endfor removes all whitespace that follows it, including the empty line. Ansible does not mind, but you now know where the lines went if you ever need them. Map keys are always visited in alphabetical order, so the order is stable between runs.
Now Ansible can use it directly, for example ansible-inventory -i inventory.ini --graph. When Terraform adds a third web server, the next apply rewrites the file automatically. (-auto-approve skips the confirmation prompt; it is fine for this local file, but think twice before using it with real infrastructure.)
This approach is simple and easy to debug. Its weakness: the file is only as fresh as the last terraform apply, and it must be shared with whoever runs Ansible.
Option 2: a dynamic inventory from the cloud
A dynamic inventory asks the cloud API for the current list of servers every time Ansible runs. For AWS, the amazon.aws.aws_ec2 inventory plugin does this. It needs the boto3 Python library and AWS credentials on the control node. Create a file whose name ends with aws_ec2.yml, for example inventory.aws_ec2.yml:
plugin: amazon.aws.aws_ec2
regions:
- ap-south-1
filters:
tag:Project: chaicart
instance-state-name: running
keyed_groups:
- key: tags.Role
prefix: role
compose:
ansible_host: public_ip_addressTerraform tags each server with Project = "chaicart" and Role = "web" or "db". The plugin finds running instances with the project tag and groups them by role, so you get groups like role_web and role_db. Tags become the contract between the two tools.
ansible-inventory -i inventory.aws_ec2.yml --graphDynamic inventories are always current and need no hand-over file. The cost is an extra dependency and cloud credentials for whoever runs Ansible.
Option 3: Terraform calls Ansible (use with care)
Terraform has provisioners, such as local-exec, that can run a command (like ansible-playbook) after creating a resource. It looks convenient, but HashiCorp's documentation calls provisioners a last resort:
- Their result is not tracked in state, so Terraform cannot tell later whether the configuration is still correct.
- If the playbook fails, the resource is marked as tainted and replaced on the next apply.
- Plans no longer show the full picture of what will happen.
A related option is the ansible/ansible Terraform provider, maintained in the Ansible ecosystem, which can record hosts and groups in Terraform state for an inventory plugin to read. Explore it once the basic patterns are comfortable.
For small first-boot steps (create a user, install Python), cloud-init user_data is a cleaner alternative, because the cloud runs it when the server starts.
Waiting for new servers
A server that Terraform just created may still be booting. Start the playbook with a task that waits until SSH works:
- name: Configure new servers
hosts: all
gather_facts: false
tasks:
- name: Wait until the server accepts SSH
ansible.builtin.wait_for_connection:
timeout: 300
- name: Gather facts now that the server is up
ansible.builtin.setup:Facts are gathered only after the connection works, because gather_facts: false stopped the automatic gathering at the start.
A typical pipeline order
terraform plan, reviewed in a pull request.terraform applycreates or updates servers and writes outputs (or tags).- Ansible builds its inventory (generated file or dynamic plugin).
ansible-playbook site.yml --check --diffpreviews the configuration.ansible-playbook site.ymlapplies it.
You will automate this in CI/CD for IaC: Plans in Pull Requests and build it by hand in the project.
Common mistakes
- Both tools managing one thing. Firewall rules in Terraform and in Ansible will fight forever.
- Hand-typed IP addresses in the inventory. They go stale after the first rebuild.
- Running Ansible from a
local-execprovisioner for everything. Failures taint servers and plans hide the real work. - Starting the playbook before SSH is ready. Use
wait_for_connection.
Key points
- Agree on ownership: Terraform builds infrastructure; Ansible configures inside servers.
templatefilewith%{ for }loops lets Terraform write an Ansible inventory from its own data.- Dynamic inventory plugins such as
amazon.aws.aws_ec2read servers from the cloud using tags. - Tags set by Terraform make a clean contract that Ansible can group by.
- Provisioners are a last resort; prefer separate steps or cloud-init for first boot.
- Use
wait_for_connectionbefore configuring freshly created servers.
Exercise
Extend inventory.tftpl so that each web server line also sets site_city from a second map variable web_cities (for example web1 = "Pune", web2 = "Kochi"). Write the changed template line and the expected [web] section of the generated file.
Show solution
%{ for name, ip in web_servers ~}
${name} ansible_host=${ip} site_city=${web_cities[name]}
%{ endfor ~}[web] web1 ansible_host=10.0.1.10 site_city=Pune web2 ansible_host=10.0.1.11 site_city=Kochi
Pass web_cities = var.web_cities in the templatefile call as well. Each host now carries its own site_city variable, which the template from the Jinja2 lesson can use directly.
Quick Quiz
1. What is the main advantage of a dynamic inventory?
The plugin queries the cloud API on every run.
2. Why are Terraform provisioners considered a last resort?
Provisioners hide work from the plan and complicate failure handling.
3. Which Ansible module waits until a newly created server accepts connections?
It retries until the connection works or the timeout ends.
Learn Terraform & Ansible with a mentor
Free lessons get you started. Our Cloud & DevOps training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
