Terraform is good at making servers exist and bad at what happens inside them, and Ansible is the opposite. What decides whether the pair works is the handoff, meaning how Ansible learns which hosts Terraform just built, and how long it waits before the first SSH connection. In our lab, VMs came back from terraform apply about 36 seconds before they accepted SSH, so a playbook launched straight after the apply fails with “No route to host” unless you plan for that gap.
This guide builds the whole Terraform and Ansible chain on one Ubuntu KVM host. Terraform (or OpenTofu) creates two VMs through libvirt and records them as Ansible hosts in its state, and Ansible reads that state through the cloud.terraform dynamic inventory plugin to install Nginx. We ran it in October 2026 on Ubuntu 26.04 with Terraform 1.16.6, OpenTofu 1.13.1 and ansible-core 2.21.5.
How Terraform and Ansible hand off
Terraform owns the lifecycle of the machines and Ansible owns their configuration, and the state file is the contract between them. Instead of rendering an inventory file from terraform output with a script, the configuration declares one ansible_host resource per VM with the ansible/ansible provider. Those resources live in the state next to the VMs, so the cloud.terraform.terraform_provider plugin can turn them into hosts and groups by running terraform show. When you add a VM it appears in the inventory on the next run, and when you destroy it the host is gone, because there is no generated file left behind to go stale.
| Job | Owner | What it touches |
|---|---|---|
| Create pool, disks, cloud-init seed, VMs | Terraform or OpenTofu | libvirt API on the host |
| Record hosts, groups and connection vars | Terraform (ansible_host) | State file |
| Build the inventory | Ansible (cloud.terraform plugin) | terraform show output |
| Wait for SSH, install and configure Nginx | Ansible playbook | The VMs over SSH |
| Remove everything | Terraform or OpenTofu | libvirt API on the host |
The diagram shows the same split on the lab host. Terraform is the only thing that writes state, and Ansible only reads it, so the two tools never disagree about which VMs exist:

Nothing in that split is specific to libvirt. You can swap the libvirt resources for your cloud’s VM resource and keep the ansible_host blocks exactly as they are, since they only need a name and an address. We tested the libvirt path only; for a hypervisor such as Proxmox, the Ansible and Proxmox guide is the place to start.
Prerequisites
You need one Linux machine that can run KVM guests, because the “cloud” in this lab is libvirt on the same box. We used a 4 vCPU, 8 GB Ubuntu 26.04 VM with nested virtualization. Treat that as a floor for following along with two 1 GB guests, not as a sizing recommendation, because a real fleet is sized by its guests. The host needs:
- Ubuntu 26.04 with
/dev/kvmpresent - about 3 GB free disk for images
- outbound HTTPS to the image, registry and Galaxy hosts
- a non-root user with sudo
Every command was run as that sudo user on Ubuntu 26.04, so other releases may need small changes. If you’re picking up Terraform from scratch alongside this, a structured book teaches state and modules better than scattered posts do, and our list of the best Terraform books ranks the ones worth your time.
Install KVM and libvirt
Ubuntu 26.04 no longer ships the old qemu-kvm package, so the install line that has been copied around for years stops right away. This is what apt printed on our host:
E: Package 'qemu-kvm' has no installation candidate
Install the real system emulator package instead, together with the libvirt daemon and client tools. On our host the full install took just under two minutes, most of it spent pulling QEMU:
sudo apt update
sudo apt install -y qemu-system-x86 qemu-utils libvirt-daemon-system libvirt-clients
Terraform talks to qemu:///system as your user, and that socket is only open to the libvirt group. Add yourself to it and to kvm, then log out and back in, because group membership is read at login and the next commands fail with a permission error until you do:
sudo usermod -aG libvirt,kvm $USER
After logging back in, check that the default NAT network is active. That network (192.168.122.0/24, with the host at 192.168.122.1) is where the VMs will live, so if it shows as inactive you should start it with sudo virsh net-start default before going on:
sudo virsh net-list --all
A healthy host lists the network as active and set to autostart. If yours says inactive, you’ll get a network error on the first apply:
Name State Autostart Persistent
--------------------------------------------
default active yes yes
The fresh install had no storage pool at all, which matters because most libvirt examples assume a pool called default. Our configuration creates its own pool, so you do not need to define one by hand. For the wider hypervisor setup (bridged networking, virt-manager, storage), the KVM and QEMU install guide goes further.
Allow libvirt to read the pool directory
This step looks optional and is not. On Ubuntu, libvirt confines every guest with a generated AppArmor profile, and that profile did not let QEMU open a base image sitting in a custom pool directory. Our first apply defined both domains and then failed to start either of them:
Error: Failed to Start Domain
with libvirt_domain.node["web01"],
on main.tf line 121, in resource "libvirt_domain" "node":
121: resource "libvirt_domain" "node" {
Domain was defined but failed to start: internal error: process exited while
connecting to monitor: 2026-10-18T02:04:57.211899Z qemu-system-x86_64:
-blockdev
{"driver":"file","filename":"/var/lib/libvirt/tf-lab/ubuntu-base.qcow2","node-name":"libvirt-3-storage","auto-read-only":true,"discard":"unmap"}:
Could not open '/var/lib/libvirt/tf-lab/ubuntu-base.qcow2': Permission denied
File ownership was fine (libvirt-qemu:kvm) and nothing showed up as DENIED in the kernel log, which is why this one wastes time. We confirmed the cause by turning the QEMU security driver off for a single test run, after which both VMs booted, and then turned it straight back on. Do not leave security_driver = "none" in place, because it removes the confinement from every guest on the host. The supported fix is a drop-in rule that the shipped libvirt abstraction already includes:
sudo mkdir -p /etc/apparmor.d/abstractions/libvirt-qemu.d
echo '/var/lib/libvirt/tf-lab/** rwk,' | sudo tee /etc/apparmor.d/abstractions/libvirt-qemu.d/tf-lab
libvirt reads that rule each time it generates a guest profile, so no service restart is needed. The abstraction is shared, so the rule applies to every libvirt guest on the host, which is fine on a lab box but worth a review on a shared hypervisor. If you change the pool path in the Terraform code, change it here too, or the same error comes back. The older /etc/apparmor.d/local/abstractions/libvirt-qemu override is marked deprecated in the shipped file, and on our host that directory did not even exist.
Install Terraform or OpenTofu
HashiCorp publishes a resolute suite, so the official repository works on Ubuntu 26.04 without any codename tricks. Add the signing key and the repository, then install Terraform:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install -y terraform
OpenTofu is a drop-in alternative, and we ran the identical configuration through it as well. Its repository is distribution-agnostic (the suite is literally called any), so you add it the same way on any Debian-family host:
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://get.opentofu.org/opentofu.gpg | sudo tee /etc/apt/keyrings/opentofu.gpg >/dev/null
curl -fsSL https://packages.opentofu.org/opentofu/tofu/gpgkey | sudo gpg --no-tty --batch --dearmor -o /etc/apt/keyrings/opentofu-repo.gpg >/dev/null
sudo chmod a+r /etc/apt/keyrings/opentofu.gpg /etc/apt/keyrings/opentofu-repo.gpg
echo "deb [signed-by=/etc/apt/keyrings/opentofu.gpg,/etc/apt/keyrings/opentofu-repo.gpg] https://packages.opentofu.org/opentofu/tofu/any/ any main" | sudo tee /etc/apt/sources.list.d/opentofu.list
sudo apt update && sudo apt install -y tofu
You only need one of the two, and they can live side by side if you want to compare. Pick one per project, though, because the inventory’s binary_path points at a single binary, and in both of our runs it named the tool that created the state. Check what you got:
terraform version
tofu version
Our host reported these releases. Yours can be newer, since both repositories track the latest stable build:
Terraform v1.16.6
on linux_amd64
OpenTofu v1.13.1
on linux_amd64
The Terraform and OpenTofu install guide covers version pinning and upgrades. You don’t need either for this lab: the configuration constrains the provider versions, and the .terraform.lock.hcl file that init writes pins the exact builds once you commit it.
Install Ansible and the cloud.terraform collection
The Ubuntu 26.04 archive carries ansible-core 2.20, one minor release behind upstream when we tested. We installed the latest release with pipx instead, because it keeps Ansible in its own virtual environment and upgrades with one command. The install took 23 seconds:
sudo apt install -y pipx
pipx install --include-deps ansible-core
pipx ensurepath
Open a new shell so ~/.local/bin is on your PATH. The inventory plugin isn’t part of ansible-core, so you install the collection that ships it:
ansible-galaxy collection install cloud.terraform
Galaxy reports the collection version it installed. Note it, because the version decides whether you need the workaround described in the troubleshooting section:
cloud.terraform:4.0.0 was installed successfully
Ansible logs into the VMs with an SSH key, and cloud-init has to place the public half on each VM at first boot. If you already have ~/.ssh/id_ed25519, skip this, since running it again would prompt you to overwrite the existing key:
ssh-keygen -t ed25519 -N "" -f ~/.ssh/id_ed25519
Terraform reads ~/.ssh/id_ed25519.pub by default, because that is the ssh_public_key variable’s default value. If your key lives elsewhere, set it once in a terraform.tfvars file next to main.tf (a single ssh_public_key = "~/.ssh/other_key.pub" line), because Terraform loads that file automatically on every plan, apply and destroy. Passing -var works too, but you would have to repeat it on each of those commands.
Write the Terraform configuration
The project holds two files at the top level and an ansible directory for the playbook side. Create the directory and open the main configuration:
mkdir -p ~/tf-ansible-lab/ansible && cd ~/tf-ansible-lab
vim main.tf
Paste the full configuration below. It is the exact file we applied, and it validates on both Terraform and OpenTofu:
terraform {
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "~> 0.9"
}
ansible = {
source = "ansible/ansible"
version = "~> 1.5"
}
}
}
provider "libvirt" {
uri = "qemu:///system"
}
variable "nodes" {
description = "VM name to static IP on the libvirt default network"
type = map(string)
default = {
web01 = "192.168.122.21"
web02 = "192.168.122.22"
}
}
variable "ssh_public_key" {
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "image_url" {
type = string
default = "https://cloud-images.ubuntu.com/resolute/current/resolute-server-cloudimg-amd64.img"
}
resource "libvirt_pool" "lab" {
name = "tf-lab"
type = "dir"
target = {
path = "/var/lib/libvirt/tf-lab"
}
create = {
build = true
start = true
autostart = true
}
}
resource "libvirt_volume" "base" {
name = "ubuntu-base.qcow2"
pool = libvirt_pool.lab.name
target = {
format = {
type = "qcow2"
}
}
create = {
content = {
url = var.image_url
}
}
}
resource "libvirt_volume" "disk" {
for_each = var.nodes
name = "${each.key}.qcow2"
pool = libvirt_pool.lab.name
capacity = 10737418240
target = {
format = {
type = "qcow2"
}
}
backing_store = {
path = libvirt_volume.base.path
format = {
type = "qcow2"
}
}
}
resource "libvirt_cloudinit_disk" "init" {
for_each = var.nodes
name = "${each.key}-init"
user_data = templatefile("${path.module}/cloud-init.yaml.tftpl", {
hostname = each.key
ssh_key = trimspace(file(pathexpand(var.ssh_public_key)))
})
meta_data = yamlencode({
instance-id = each.key
local-hostname = each.key
})
network_config = yamlencode({
version = 2
ethernets = {
primary = {
match = { name = "en*" }
addresses = ["${each.value}/24"]
routes = [{ to = "default", via = "192.168.122.1" }]
nameservers = { addresses = ["192.168.122.1"] }
}
}
})
}
resource "libvirt_volume" "seed" {
for_each = var.nodes
name = "${each.key}-seed.iso"
pool = libvirt_pool.lab.name
create = {
content = {
url = libvirt_cloudinit_disk.init[each.key].path
}
}
}
resource "libvirt_domain" "node" {
for_each = var.nodes
name = each.key
type = "kvm"
memory = 1024
memory_unit = "MiB"
vcpu = 1
running = true
os = {
type = "hvm"
type_arch = "x86_64"
type_machine = "q35"
}
features = {
acpi = true
apic = {}
}
devices = {
disks = [
{
source = {
volume = {
pool = libvirt_volume.disk[each.key].pool
volume = libvirt_volume.disk[each.key].name
}
}
target = {
bus = "virtio"
dev = "vda"
}
driver = {
type = "qcow2"
}
},
{
device = "cdrom"
source = {
volume = {
pool = libvirt_volume.seed[each.key].pool
volume = libvirt_volume.seed[each.key].name
}
}
target = {
bus = "sata"
dev = "sda"
}
}
]
interfaces = [
{
type = "network"
model = { type = "virtio" }
source = {
network = {
network = "default"
}
}
}
]
}
}
resource "ansible_host" "node" {
for_each = var.nodes
name = each.key
groups = ["web"]
variables = {
ansible_host = each.value
ansible_user = "ansible"
}
depends_on = [libvirt_domain.node]
}
output "nodes" {
value = var.nodes
}
You don’t have to change anything in it to follow along. Each of these choices came from something that broke or mattered in the lab:
for_eachover a name-to-IP map- static IPs through cloud-init
network_config features.acpi = trueon every domain- one base image with copy-on-write overlays
depends_onon theansible_hostresources
The map keys resources by name, so adding web03 later plans only its own resources and never reshuffles the others. Static addresses are known at plan time, which means they land in the inventory without a DHCP lease lookup that could race the boot. Without ACPI the Ubuntu guests hang at boot (see the troubleshooting section), the shared base image means the 865 MB download happens once, and the dependency keeps a host out of the inventory until its VM exists.
The libvirt provider’s current 0.9 line is a full rewrite whose schema mirrors libvirt’s XML, so older examples with network_interface or disk blocks will not validate against it. Field names in this file follow the provider documentation, and you should check there first when a field you copied from an old post is rejected.
The cloud-init user data is a template, which keeps YAML out of the HCL and avoids heredocs. Create it next to main.tf:
vim cloud-init.yaml.tftpl
It creates an ansible user with your public key and passwordless sudo, which is everything Ansible needs. Password logins stay off, so the key is the only way in:
#cloud-config
hostname: ${hostname}
users:
- name: ansible
groups: [sudo]
shell: /bin/bash
sudo: "ALL=(ALL) NOPASSWD:ALL"
ssh_authorized_keys:
- ${ssh_key}
ssh_pwauth: false
package_update: false
Leave the ${hostname} and ${ssh_key} placeholders exactly as written, because templatefile() in main.tf fills them in per VM. Terraform renders one copy per VM, which is how web01 and web02 got their own hostnames from a single file.
Provision the VMs
Initialize the working directory first. Terraform downloads both providers and writes a lock file, and you should commit that file with the code so every machine gets the same provider builds:
terraform init
The provider lines show what got pinned. The libvirt provider is self-signed by its author, which is normal for community providers and not an error:
- Installing dmacvicar/libvirt v0.9.9...
- Installed dmacvicar/libvirt v0.9.9 (self-signed, key ID 0833E38C51E74D26)
- Installing ansible/ansible v1.5.0...
- Installed ansible/ansible v1.5.0 (signed by a HashiCorp partner, key ID 41F01D0480007165)
Review the plan before you apply anything. For two VMs it should create 12 resources: the pool and the base image, plus five per VM (disk, cloud-init disk, seed volume, domain and ansible_host):
terraform plan
If your count differs, read the plan before going further, because an unexpected replace usually means a typo in the map. Ours matched:
Plan: 12 to add, 0 to change, 0 to destroy.
Apply it. Most of the time goes into downloading the Ubuntu cloud image, while the domains themselves take about two seconds each:
terraform apply -auto-approve
On our host the complete apply took 57 seconds, and 53 of those were the base image download. This is the tail of the output:
libvirt_volume.base: Creation complete after 53s [id=/var/lib/libvirt/tf-lab/ubuntu-base.qcow2]
libvirt_volume.disk["web02"]: Creation complete after 0s [id=/var/lib/libvirt/tf-lab/web02.qcow2]
libvirt_volume.disk["web01"]: Creation complete after 0s [id=/var/lib/libvirt/tf-lab/web01.qcow2]
libvirt_domain.node["web02"]: Creation complete after 2s [name=web02]
libvirt_domain.node["web01"]: Creation complete after 2s [name=web01]
ansible_host.node["web01"]: Creation complete after 0s [id=web01]
ansible_host.node["web02"]: Creation complete after 0s [id=web02]
Apply complete! Resources: 12 added, 0 changed, 0 destroyed.
With OpenTofu, the commands are tofu init and tofu apply against the same files. Our OpenTofu apply took 61 seconds and produced the same 12 resources, and tofu init noted that it skipped signature validation because the OpenTofu registry holds no GPG keys for these two providers. That is a registry difference, not a broken download, so you can carry on.
Point Ansible at the Terraform state
The inventory is a three-line plugin config. Move into the Ansible directory and create it:
cd ~/tf-ansible-lab/ansible
vim inventory.yml
project_path points at the Terraform directory one level up. The binary_path line is required with the current collection on ansible-core 2.21 (the troubleshooting section explains why), and you should set it to /usr/bin/tofu if OpenTofu manages the state. The plugin resolves the relative .. from the directory you run Ansible in, not from where inventory.yml lives, so run every Ansible command from ~/tf-ansible-lab/ansible:
plugin: cloud.terraform.terraform_provider
project_path: ..
binary_path: /usr/bin/terraform
Next, create an ansible.cfg in the same directory. With it, every command you run from here picks up that inventory without a -i flag:
vim ansible.cfg
Host key checking is off on purpose. Every destroy and apply cycle gives the same IP a new host key, and with checking on SSH would refuse the changed key until you cleaned known_hosts by hand. Keep it on for servers that live longer than a lab. The enable_plugins line limits inventory parsing in this directory to the Terraform plugin, so a stray INI file next to it is never read as a second inventory:
[defaults]
inventory = inventory.yml
host_key_checking = False
interpreter_python = auto_silent
[inventory]
enable_plugins = cloud.terraform.terraform_provider
Now ask Ansible what it sees. The plugin runs terraform show in the project directory, so that directory has to be initialized, or the inventory comes back empty:
ansible-inventory --graph --vars
Both VMs show up in the web group with the address and user that Terraform recorded. If you see only @all and @ungrouped, the plugin failed to load, and the warning printed above the graph tells you why:
@all:
|--@ungrouped:
|--@web:
| |--web01
| | |--{ansible_host = 192.168.122.21}
| | |--{ansible_user = ansible}
| |--web02
| | |--{ansible_host = 192.168.122.22}
| | |--{ansible_user = ansible}
Nothing in this inventory is written by hand, so it can’t drift from what Terraform built. For other sources such as cloud APIs, constructed groups and caching, the dynamic inventory tutorial picks up from here.
Handle the SSH timing gap
Terraform marks a domain as created as soon as libvirt starts it, which is well before the guest has booted, run cloud-init and started sshd. We measured the gap on a fresh apply by pinging both hosts straight away and then polling port 22:
ansible web -m ansible.builtin.ping
Both hosts failed immediately with exit code 4, because the guests had not brought up their network yet. This is web01’s half of the output:
web01 | UNREACHABLE! => {
"changed": false,
"msg": "Task failed: Failed to connect to the host via ssh: ssh: connect to host 192.168.122.21 port 22: No route to host",
"unreachable": true
}
SSH on web01 answered 35.7 seconds after the apply finished, and cloud-init reported done at 39.9 seconds. The guest’s own systemd-analyze put its boot at 30.9 seconds. Those numbers come from a nested VM on our hardware, so do not hardcode a sleep based on them, because a busier host or a bigger image will move them. Let the playbook do the waiting instead:
vim site.yml
The first play skips fact gathering, since facts need the very connection we are waiting for. wait_for_connection retries until a real Ansible ping works. After that, cloud-init status --wait blocks until first boot is finished, so your package task never races cloud-init’s own apt run:
---
- name: Wait for the new VMs to accept SSH
hosts: web
gather_facts: false
tasks:
- name: Wait for connection
ansible.builtin.wait_for_connection:
delay: 5
timeout: 300
- name: Wait for cloud-init to finish
ansible.builtin.command: cloud-init status --wait
changed_when: false
- name: Configure the web tier
hosts: web
become: true
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Publish a page that names the host
ansible.builtin.copy:
dest: /var/www/html/index.html
content: "Served by {{ inventory_hostname }} ({{ ansible_facts['default_ipv4']['address'] }})\n"
owner: root
group: root
mode: "0644"
- name: Keep Nginx running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
Before the real run, ansible-playbook --syntax-check site.yml catches indentation mistakes in a second. The playbook tutorial covers the modules and handler patterns used here in more detail.
Run the playbook
Run it straight after an apply to prove the wait works. If you run it later, the wait tasks return at once, so the same playbook is safe either way:
ansible-playbook site.yml
From a fresh apply, the playbook finished in 83 seconds and the whole chain (apply plus playbook) in 140 seconds. Most of the playbook time is the wait and the package install:
PLAY [Wait for the new VMs to accept SSH] **************************************
TASK [Wait for connection] *****************************************************
ok: [web01]
ok: [web02]
TASK [Wait for cloud-init to finish] *******************************************
ok: [web02]
ok: [web01]
PLAY [Configure the web tier] **************************************************
TASK [Gathering Facts] *********************************************************
ok: [web01]
ok: [web02]
TASK [Install Nginx] ***********************************************************
changed: [web02]
changed: [web01]
TASK [Publish a page that names the host] **************************************
changed: [web02]
changed: [web01]
TASK [Keep Nginx running and enabled] ******************************************
ok: [web01]
ok: [web02]
PLAY RECAP *********************************************************************
web01 : ok=6 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=6 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The service task reports ok rather than changed because the Ubuntu Nginx package starts and enables the service on install, so Ansible finds nothing to do. Each VM now serves a page with its own name and address, which proves the facts came from the right host:
curl -s 192.168.122.21; curl -s 192.168.122.22
Both answer from the default network, so you can reach them from the host but not from your LAN:
Served by web01 (192.168.122.21)
Served by web02 (192.168.122.22)
The capture below shows the Ansible side, run from ~/tf-ansible-lab/ansible. It covers the inventory graph, the first play recap, the second run that changed nothing, and the curl check:

At this point the core workflow is done: two VMs built from code and configured from an inventory nobody typed. The rest of the guide proves it holds up on a second run and cleans up after itself.
Prove idempotency and drift correction
A second pass of both tools must change nothing, and that property is what makes the pair safe to run from CI on every merge. Plan with a detailed exit code from the Terraform directory:
cd ~/tf-ansible-lab
terraform plan -detailed-exitcode
Exit code 0 and this message mean the real VMs match the code. An exit code of 2 means pending changes, so that is the value a CI job should alert on:
No changes. Your infrastructure matches the configuration.
Run the playbook again from the ansible directory and the recap should read changed=0 on both hosts, which ours did. Drift is where the pairing earns its keep. We stopped Nginx on web01 by hand and reran the playbook, and only that host reported a change, because every other task found the system already in the declared state:
changed: [web01]
web01 : ok=6 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web02 : ok=6 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Scaling follows the same rule. When we added a third entry to the nodes map and planned, Terraform showed only web03’s five resources and replaced nothing, since for_each keys resources by name instead of list position. count keys them by list position instead, which is why we avoided it.
Destroy the lab
One command removes the VMs, the volumes, the pool and its directory. The inventory entries go with them, since they’re resources in the same state:
terraform destroy -auto-approve
The destroy took under four seconds. Afterwards virsh list --all was empty and ansible-inventory --graph showed no hosts, because the state no longer held any ansible_host resources:
Destroy complete! Resources: 12 destroyed.
One thing the destroy does not undo is the AppArmor drop-in and the libvirt packages, so remove those by hand if the host goes back to other work. The Terraform side of the same cycle, run from ~/tf-ansible-lab, looks like this from apply to an empty virsh list:

You can run apply and the playbook again at any time to get the same two hosts back, because nothing outside the project directory holds state.
Troubleshoot the errors we hit
Three failures cost us real time in this build. The AppArmor “Permission denied” on the base image is covered in the libvirt step above, because you need that fix before the first apply. The other two follow.
VMs boot but never reach the network
The first working apply produced two running domains that never answered. There was no DHCP lease, ip neigh showed both addresses as FAILED, and wait_for_connection gave up after 300 seconds. With a serial log attached, the guest showed why (excerpt, with the console spinner characters stripped):
[ 0.008398] ACPI BIOS Error (bug): A valid RSDP was not found (20251212/tbxfroot-222)
[ 0.008402] ACPI: Failed to initialize tables, status=0x5 (AE_NOT_FOUND)
(2 of 2) Job dracut-initqueue.service/start running (46s / no limit)
(1 of 2) Job dev-disk-by\x2dlabel-cβ¦ice/start running (45s / no limit)
The domain had no ACPI, so the kernel could not enumerate the PCIe devices on the q35 machine, and dracut waited forever for the root disk. The rewritten provider maps the domain XML literally and adds no features you did not ask for, and its own two-VM example leaves them out. Adding features = { acpi = true, apic = {} } to the domain fixed it, and both guests reached SSH in under 40 seconds.
get_bin_path() got an unexpected keyword argument ‘required’
On ansible-core 2.21 the inventory plugin failed with this warning. Ansible didn’t stop, it fell back to an empty inventory:
[WARNING]: Failed to parse inventory with 'ansible_collections.cloud.terraform.plugins.inventory.terraform_provider' plugin: get_bin_path() got an unexpected keyword argument 'required'
ansible-core 2.21 removed the required option from get_bin_path, but cloud.terraform 4.0.0 still passes it when it searches PATH for the terraform binary (issue #214 upstream). An absolute binary_path in inventory.yml skips that code path, so the plugin loads. The dangerous part is the exit code. With the broken inventory, ansible-playbook site.yml printed “skipping: no hosts matched” and still exited 0, so a CI job would report success after configuring nothing. Add a guard such as ansible-inventory --list | grep -q web01 before the playbook in any pipeline.
Harden the workflow for a team
The lab keeps state on local disk and runs both tools by hand. Before several people or a CI system share this setup, a few things should change:
- remote state with locking
- the provider lock file committed
- host key checking back on
- playbook secrets in Ansible Vault
- an inventory check before every run
Remote state matters more here than usual, because the inventory plugin reads whatever terraform show returns, so two engineers with different local states would configure different hosts. Secrets belong in Ansible Vault rather than in Terraform variables, since anything Terraform touches ends up in state in plain text.
The pipeline order stays the same as in this lab: plan on pull requests, apply on merge, then the playbook with its wait tasks, then a second plan with -detailed-exitcode as the drift check. If you want Terraform on KVM without the Ansible half, our guide to provisioning KVM VMs with Terraform covers the provider in more depth, and the rest of this series is mapped out in the Ansible automation guide.