Ansible

Terraform and Ansible: Provision Then Configure

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.

Original content from computingforgeeks.com - post 165302

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.

AspectTerraformAnsible
Primary roleInfrastructure provisioning (Day 0)Configuration management (Day 1+)
State managementMaintains state file tracking all resourcesStateless, checks current state each run
Agent modelAgentless (API calls to providers)Agentless (SSH/WinRM to targets)
LanguageHCL (HashiCorp Configuration Language)YAML (playbooks) + Jinja2 (templates)
Best forVMs, networks, load balancers, DNS, cloud resourcesPackage installs, config files, services, users, security hardening
IdempotencyCompares desired state to state fileModule-level idempotency checks
LifecycleCreate, update, destroy infrastructureConfigure, 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: pve01 and pve02)
  • 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_ed25519 and ~/.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 rocky user.
  • 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.

Related Articles

Ansible How To Install Ansible AWX on CentOS 7 / RHEL 7 Automation How To Setup Chef Infra Server on CentOS 8 / RHEL 8 Ansible Your First Ansible Playbook: Step-by-Step Guide Automation Setup GitLab Runner on Rocky Linux 10 / Ubuntu 24.04

Leave a Comment

Press ESC to close