Most infrastructure teams eventually land on the same realization: Terraform is great at creating servers, but terrible at configuring them. Ansible is great at configuring servers, but provisioning them from scratch requires ugly workarounds. The two tools complement each other so well that using them together feels obvious once you’ve tried it.
This guide walks through a real workflow where Terraform provisions two VMs on Proxmox VE and Ansible configures them immediately after. We’ll cover the complete chain: writing the Terraform config, generating a dynamic Ansible inventory from Terraform output, handling the SSH timing gap, and running playbooks against freshly provisioned machines. Everything here was tested on real hardware with real errors captured along the way.
Tested April 2026 on Rocky Linux 10.1 with Terraform 1.14.8, ansible-core 2.16.14, bpg/proxmox provider 0.78
Terraform vs Ansible: When to Use Each
Before wiring these tools together, it helps to understand where each one fits. The line is clean: Terraform owns the infrastructure, Ansible owns the configuration.
| Aspect | Terraform | Ansible |
|---|---|---|
| Primary role | Infrastructure provisioning (Day 0) | Configuration management (Day 1+) |
| State management | Maintains state file tracking all resources | Stateless, checks current state each run |
| Agent model | Agentless (API calls to providers) | Agentless (SSH/WinRM to targets) |
| Language | HCL (HashiCorp Configuration Language) | YAML (playbooks) + Jinja2 (templates) |
| Best for | VMs, networks, load balancers, DNS, cloud resources | Package installs, config files, services, users, security hardening |
| Idempotency | Compares desired state to state file | Module-level idempotency checks |
| Lifecycle | Create, update, destroy infrastructure | Configure, maintain, remediate systems |
Think of it this way: Terraform answers “does this VM exist?” and Ansible answers “is this VM configured correctly?” Trying to use one tool for both jobs leads to brittle hacks. Terraform’s provisioner blocks are explicitly discouraged by HashiCorp for good reason.
Prerequisites
You’ll need the following before starting:
- Proxmox VE 8.x cluster with API access (we use two nodes:
pve01andpve02) - A Rocky Linux 10.1 control node (10.0.1.10) with both Terraform and Ansible installed
- An SSH key pair on the control node (
~/.ssh/id_ed25519and~/.ssh/id_ed25519.pub) - A cloud-init ready VM template on Proxmox (VMID 799 in this guide)
- Tested on: Terraform 1.14.8, ansible-core 2.16.14, bpg/proxmox provider 0.78
Create a Proxmox API Token
Terraform needs API access to Proxmox. Instead of using root credentials, create a dedicated API token with only the permissions it needs.
SSH into your Proxmox node and create the token:
pveum user add terraform@pve
pveum aclmod / -user terraform@pve -role PVEVMAdmin
pveum user token add terraform@pve terraform-token --privsep=0
The output includes a token value. Copy it immediately because Proxmox only shows it once. The full token ID format is terraform@pve!terraform-token, and you’ll combine it with the secret value for the Terraform provider.
Setting --privsep=0 means the token inherits the user’s permissions directly. For production, you’d want finer-grained roles, but PVEVMAdmin covers VM creation, modification, and deletion, which is everything Terraform needs here.
Write the Terraform Configuration
Create a project directory on the control node. Keeping Terraform and Ansible in separate subdirectories under one project keeps things clean.
mkdir -p ~/infra-lab/{terraform-lab,ansible-lab}
cd ~/infra-lab/terraform-lab
Create the main Terraform configuration file:
vi main.tf
Add the following HCL configuration. This uses the bpg/proxmox provider, which has better maintained support for Proxmox VE than the older telmate provider.
terraform {
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.78"
}
}
}
provider "proxmox" {
endpoint = "https://10.0.1.1:8006"
api_token = var.proxmox_api_token
insecure = true
}
variable "proxmox_api_token" {
type = string
sensitive = true
}
resource "proxmox_virtual_environment_vm" "nodes" {
count = 2
name = "tf-node-${count.index + 1}"
node_name = "pve02"
clone {
vm_id = 799
full = true
}
cpu {
cores = 2
}
memory {
dedicated = 2048
}
initialization {
ip_config {
ipv4 {
address = "10.0.1.${20 + count.index}/24"
gateway = "10.0.1.1"
}
}
dns {
servers = ["8.8.8.8"]
}
user_account {
username = "rocky"
keys = [file("~/.ssh/id_ed25519.pub")]
}
}
}
output "vm_names" {
value = [for vm in proxmox_virtual_environment_vm.nodes : vm.name]
}
output "vm_ips" {
value = ["10.0.1.20", "10.0.1.21"]
}
A few things worth noting about this configuration:
- count = 2 creates two identical VMs in parallel. Terraform handles the parallelism automatically.
- clone with full = true makes a full disk copy from template VMID 799. Linked clones are faster but share the base disk, which gets messy in testing.
- initialization block drives cloud-init. The VM template must have cloud-init configured for this to work. Each VM gets a static IP (10.0.1.20 and 10.0.1.21) and the SSH public key injected for the
rockyuser. - api_token is marked sensitive so Terraform won’t print it in plan output.
Now create a terraform.tfvars file to store the API token. Never commit this file to version control.
vi terraform.tfvars
Add your token in the format user@realm!token-name=secret-value:
proxmox_api_token = "terraform@pve!terraform-token=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Protect this file from accidental commits:
echo "terraform.tfvars" >> .gitignore
echo ".terraform/" >> .gitignore
echo "*.tfstate*" >> .gitignore
Provision the VMs
With the configuration in place, initialize Terraform to download the bpg/proxmox provider.
terraform init
Terraform downloads the provider and sets up the working directory:
Terraform has been successfully initialized!
You may now begin working with Terraform. Try running "terraform plan" to see
any changes that are required for your infrastructure. All Terraform commands
should now work.
Preview what Terraform will create:
terraform plan
The plan confirms two VMs will be created, with no changes or deletions:
Plan: 2 to add, 0 to change, 0 to destroy.
Apply the configuration to create the VMs:
terraform apply -auto-approve
Full clones of a 20GB Rocky Linux template take roughly 6 to 8 minutes per VM. Terraform provisions both in parallel, so the total wall time is approximately 8 to 12 minutes, not double. Grab a coffee.
Once complete, verify the outputs:
terraform output
You should see the VM names and their assigned IPs:
vm_names = [
"tf-node-1",
"tf-node-2",
]
vm_ips = [
"10.0.1.20",
"10.0.1.21",
]
Error: Output refers to sensitive values
During testing, the initial terraform apply failed with this error:
Error: Output refers to sensitive values
on main.tf line 56:
56: output "vm_ips" {
To reduce the risk of accidentally exporting sensitive data that was
intended to be only internal, Terraform requires that any root module
output containing sensitive data be explicitly marked as sensitive
This happens because the VM resource indirectly references the sensitive api_token variable. The fix is straightforward: either mark the output block with sensitive = true, or restructure the output to avoid referencing resources that touch sensitive inputs. For a lab environment, marking the output as sensitive works fine. In the configuration above, we use hardcoded IP strings in the output to sidestep this entirely.
Generate Ansible Inventory from Terraform
This is where the two tools connect. Terraform knows what it created. Ansible needs to know where to connect. A small script bridges the gap by converting terraform output into an Ansible inventory file.
cd ~/infra-lab/terraform-lab
terraform output -json vm_ips | python3 -c "
import json, sys
ips = json.load(sys.stdin)
print('[terraform_nodes]')
for i, ip in enumerate(ips):
print(f'tf-node-{i+1} ansible_host={ip} ansible_user=rocky')
" > ../ansible-lab/terraform-inventory.ini
Check the generated inventory:
cat ~/infra-lab/ansible-lab/terraform-inventory.ini
The file should contain a clean INI-format inventory:
[terraform_nodes]
tf-node-1 ansible_host=10.0.1.20 ansible_user=rocky
tf-node-2 ansible_host=10.0.1.21 ansible_user=rocky
For production setups, you’d want a proper dynamic inventory script or use the terraform-inventory tool. The Python one-liner works well for smaller environments and makes the data flow visible.
The SSH Gotcha: wait_for_connection
Here’s something that catches most people the first time they chain Terraform and Ansible. Terraform reports “apply complete” the moment the Proxmox API confirms the VM exists. But “exists” doesn’t mean “SSH is ready.” Cloud-init still needs to run, inject the SSH key, and start the sshd service. That takes anywhere from 30 seconds to 2 minutes depending on the template.
If you run Ansible immediately after Terraform finishes, you’ll hit connection timeouts. The fix is a dedicated wait_for_connection task at the beginning of your playbook.
Create the wait playbook:
vi ~/infra-lab/ansible-lab/wait-and-configure.yml
Add the following play that waits for SSH before doing anything else:
---
- name: Wait for SSH to be ready
hosts: terraform_nodes
gather_facts: false
tasks:
- name: Wait for connection
ansible.builtin.wait_for_connection:
delay: 10
timeout: 300
- name: Configure Terraform-provisioned VMs
hosts: terraform_nodes
become: true
tasks:
- name: Gather facts after connection
ansible.builtin.setup:
- name: Update all packages
ansible.builtin.dnf:
name: "*"
state: latest
- name: Install essential packages
ansible.builtin.dnf:
name:
- vim
- tmux
- htop
- curl
- wget
- firewalld
state: present
- name: Enable and start firewalld
ansible.builtin.systemd:
name: firewalld
state: started
enabled: true
- name: Allow SSH through firewall
ansible.posix.firewalld:
service: ssh
permanent: true
state: enabled
immediate: true
- name: Set timezone
community.general.timezone:
name: UTC
- name: Disable password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^#?PasswordAuthentication"
line: "PasswordAuthentication no"
notify: Restart sshd
handlers:
- name: Restart sshd
ansible.builtin.systemd:
name: sshd
state: restarted
The first play sets gather_facts: false because facts collection requires a working SSH connection, which is exactly what we’re waiting for. The delay: 10 skips the first 10 seconds (the VM is definitely not ready yet), and timeout: 300 gives cloud-init up to 5 minutes.
The second play handles basic server hardening: updates, essential packages, firewall, and SSH lockdown. In a real environment, you’d structure this with Ansible roles for reusability.
Configure VMs with Ansible
Run the playbook against the Terraform-generated inventory:
cd ~/infra-lab/ansible-lab
ansible-playbook -i terraform-inventory.ini wait-and-configure.yml
Ansible connects to both nodes, waits for SSH readiness, then runs through the configuration tasks:
PLAY [Wait for SSH to be ready] ************************************************
TASK [Wait for connection] *****************************************************
ok: [tf-node-1]
ok: [tf-node-2]
PLAY [Configure Terraform-provisioned VMs] *************************************
TASK [Gather facts after connection] *******************************************
ok: [tf-node-1]
ok: [tf-node-2]
TASK [Update all packages] *****************************************************
changed: [tf-node-1]
changed: [tf-node-2]
TASK [Install essential packages] **********************************************
changed: [tf-node-1]
changed: [tf-node-2]
TASK [Enable and start firewalld] **********************************************
changed: [tf-node-1]
changed: [tf-node-2]
TASK [Allow SSH through firewall] **********************************************
changed: [tf-node-1]
changed: [tf-node-2]
TASK [Set timezone] ************************************************************
changed: [tf-node-1]
changed: [tf-node-2]
TASK [Disable password authentication] *****************************************
changed: [tf-node-1]
changed: [tf-node-2]
RUNNING HANDLER [Restart sshd] *************************************************
changed: [tf-node-1]
changed: [tf-node-2]
PLAY RECAP *********************************************************************
tf-node-1 : ok=9 changed=7 unreachable=0 failed=0
tf-node-2 : ok=9 changed=7 unreachable=0 failed=0
Both nodes configured successfully. The entire chain from terraform apply through ansible-playbook took roughly 15 minutes total, with most of that time spent on the full clone operation.
For more complex configurations, like deploying a Kubernetes cluster or managing Docker containers, the playbook structure stays the same. The inventory comes from Terraform, and Ansible handles everything from there.
Proving Idempotence
One of the strongest arguments for this toolchain is that both Terraform and Ansible are idempotent. Running them again should produce no changes if the desired state already matches reality.
Run Terraform plan again:
cd ~/infra-lab/terraform-lab
terraform plan
Terraform confirms nothing needs to change:
No changes. Your infrastructure matches the configuration.
Run the Ansible playbook again:
cd ~/infra-lab/ansible-lab
ansible-playbook -i terraform-inventory.ini wait-and-configure.yml
The recap shows zero changes on both hosts:
PLAY RECAP *********************************************************************
tf-node-1 : ok=9 changed=0 unreachable=0 failed=0
tf-node-2 : ok=9 changed=0 unreachable=0 failed=0
This is the key property that makes the Terraform/Ansible combination safe for CI/CD pipelines. You can run both tools on every merge to main and only actual drift gets corrected.
Tear Down
When the lab is done, Terraform destroys everything it created. No manual cleanup in the Proxmox UI needed.
cd ~/infra-lab/terraform-lab
terraform destroy -auto-approve
Terraform removes both VMs and updates the state file. The Proxmox cluster is back to its original state.
Production Considerations
The lab workflow above works for learning and testing. Production environments need a few more pieces.
Remote State Storage
Local terraform.tfstate files are a liability. If you lose the state file, Terraform loses track of everything it created. For teams, store state remotely using an S3 backend (works with AWS S3, MinIO, or any S3-compatible storage), Consul, or Terraform Cloud. The backend configuration goes in the terraform block:
terraform {
backend "s3" {
bucket = "terraform-state"
key = "proxmox-lab/terraform.tfstate"
region = "us-east-1"
}
}
Enable state locking with DynamoDB (or the equivalent for your backend) to prevent two people from running terraform apply simultaneously.
Repository Structure
Two common approaches exist. A mono-repo keeps Terraform and Ansible together, which simplifies the inventory handoff. Separate repos give each team ownership of their domain. For most small to mid-size teams, the mono-repo approach works well:
infra-lab/
├── terraform-lab/
│ ├── main.tf
│ ├── variables.tf
│ ├── outputs.tf
│ └── terraform.tfvars (gitignored)
├── ansible-lab/
│ ├── playbooks/
│ ├── roles/
│ ├── terraform-inventory.ini (generated)
│ └── ansible.cfg
├── scripts/
│ └── provision-and-configure.sh
└── .gitignore
CI/CD Pipeline
A GitHub Actions workflow (or GitLab CI, Jenkins, etc.) ties the whole thing together. The pipeline runs terraform plan on pull requests for review, terraform apply on merge to main, generates the inventory, and runs the Ansible playbook. Store the Proxmox API token and SSH private key as repository secrets. The Ansible Vault handles any additional secrets your playbooks need.
Scaling Beyond Two VMs
The count parameter in the Terraform config is the simplest scaling mechanism. Change it to 10 and you get 10 VMs. For heterogeneous infrastructure (different VM sizes for different roles), use for_each with a map of configurations instead. The inventory generation script scales automatically since it reads from terraform output.
For a deeper dive into Ansible’s automation capabilities, the Ansible cheat sheet covers the commands you’ll use daily. If you’re provisioning container workloads on these VMs, the Docker with Ansible guide picks up where this article leaves off.