Intro
Part 2 rebuilt the VMs by hand because the Terraform setup was broken: its CI needed a self-hosted runner that no longer existed, and its state still listed thirteen VMs I had deleted. This part closes the loop: a runner inside the new cluster, a clean state, and the new VMs imported back into Terraform.
TL;DR: Actions Runner Controller 0.15.0 gives autoscaling runners (0 to 3) inside the cluster. The Terraform
VM.Monitorerror on Proxmox VE 9 is a provider bug, not a permission problem.removedblocks withdestroy = falsedropped the old VMs from state without destroying seven live VMs that reused their VMIDs. Finally,importblocks brought the eight new VMs into bpg/proxmox 0.116.0 with8 imported, 0 changed.
The Situation
- GitHub org repo with
Terraform PlanandTerraform Applyworkflows that run on[self-hosted, linux, x64, homelab], runner grouphomelab - AWS credentials through GitHub OIDC, state in S3
- Telmate/proxmox
3.0.2-rc03, a Proxmox API token per workflow
Setup
Autoscaling runners in the cluster
ARC runner scale sets now support custom labels, so the existing workflows work unchanged:
githubConfigUrl: https://github.com/<org>
githubConfigSecret: arc-github-app
runnerScaleSetName: homelab
runnerGroup: homelab
scaleSetLabels: [self-hosted, linux, x64, homelab]
minRunners: 0
maxRunners: 3
Authentication is a GitHub App with the org permission “Self-hosted runners: Read and write”. Before creating the Secret, I minted an installation token from the key to check the permissions:
installation account: <org> | repo selection: selected
permissions: {'organization_self_hosted_runners': 'write', ...}
A Plan job that had been queued for three hours got picked up within seconds: a runner pod appeared, ran the job, and the scale set went back to zero.
Issue 1: The VM.Monitor Error
Symptom
permissions for user/token terraform-plan@pve are not sufficient,
please provide also the following permissions that are missing: [VM.Monitor]
The user already had Administrator on /.
Cause
Proxmox VE 9 removed the VM.Monitor privilege. Not even Administrator
has it anymore. Telmate/proxmox 3.0.2-rc03 still checks for it.
Fix
Bump the provider: 3.0.2-rc04 dropped the check. Adding permissions can’t fix
a privilege that no longer exists.
Issue 2: A Cleanup Plan That Wanted to Destroy the New Cluster
Symptom
The PR that deleted the old VM resources planned this:
Plan: 0 to add, 0 to change, 7 to destroy.
# proxmox_vm_qemu.controller["controller-1"] will be destroyed
The old VMs were already gone. What would it destroy?
Cause
The rebuilt VMs reused seven of the old VMIDs (200, 300, 400, 101, 102, 201, 202). During refresh, Terraform found live VMs under those IDs and treated them as its own. Removing the resource blocks meant destroying them.
Fix
Forget the resources instead of destroying them:
removed {
from = proxmox_vm_qemu.controller
lifecycle {
destroy = false
}
}
The plan became 0 to destroy, and the apply only cleaned the state.
Issue 3: Importing Hand-Built VMs
Symptom
Telmate’s import fidelity is poor: attributes it can’t read back can force a replacement of a running node.
Fix
I switched to bpg/proxmox 0.116.0, wrote one for_each resource from the
live qm config values (MACs, disks, cloud-init, tags), and used import
blocks, which accept for_each since Terraform 1.7:
import {
for_each = local.k0s_vms
to = proxmox_virtual_environment_vm.k0s[each.key]
id = "${each.value.node}/${each.value.vm_id}"
}
prevent_destroy guards every VM. CI builds bpg’s PROXMOX_VE_API_TOKEN from
the existing secrets, so no new secret was needed. The first plan was clean:
Plan: 8 to import, 0 to add, 0 to change, 0 to destroy.
Apply complete! Resources: 8 imported, 0 added, 0 changed, 0 destroyed.
Lessons
- Read the plan, every line. A cleanup PR is exactly where a destroy hides.
- Reused IDs make state dangerous. Terraform binds resources by ID, not by intent.
- An error naming a permission isn’t always about permissions. Check whether the permission still exists.
- Import with
importblocks in a PR. The plan shows what the import would change before anything happens.
Series
This post is part of Homelab Rebuild:
- Part 1: Upgrading Proxmox VE 8 to 9
- Part 2: Starting Over When the IaC Is Broken
- Part 3: When Tailscale Breaks Your Cilium Gateway
- Part 4: Living on the Tailnet
- Part 5: Secrets Without Secrets in Git
- Part 6: Certificates, DNS and a Private CA
- Part 7: Self-Hosted CI and Getting Back to IaC (this post)
- Part 8: Stateful Workloads on Unequal Hardware
- Part 9: Going Public Safely with Cloudflare Tunnel
- Part 10: When the Hypervisor Went Quiet
- Part 11: Hardening the Cluster in Five Layers
- Part 12: Observability with Coroot, and Losing pve-01
- Part 13: The Host That Kept Losing Power
- Part 14: Observability, Take Two with OpenTelemetry
- Part 15: Requests and Limits for Everything
- Part 16: Handing the Cluster to FluxCD
- Part 17: Making the Guardrails Earn Their Keep
- Part 18: When the API Server Outgrew Its VM
- Part 19: Logging In to OpenBao with Cloudflare Access and Google
- Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access
- Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
- Part 22: Turning Four Days of Fixes into AI Runbooks
- Part 23: microVM Sandboxes on Proxmox
- Part 24: Pi-hole Without a Home Host
- Part 25: It Was the Router
- Part 26: TLS on the Front, Plaintext Behind It
- Part 27: One Bad Power Adapter, Two Different Deaths
- Part 28: Observability, Take Three: Making It Worth Looking At
- Part 29: One Meter for Every Model, LiteLLM on Workers AI
- Part 30: Proxmox Joins the SSO, and a Plan Token Loses Admin
- Part 31: First Backups, Postgres, OpenBao and etcd to R2
- Part 32: Alerts That Reach Me, Even When the Lab Is Dead
- Part 33: The Firewall Held, the Tailnet Did Not
- Part 34: From Watching to Blocking, and a Record of Who Did What
- Part 35: Wazuh Without the 8 GB, Endpoint Security for One Mac and Three Hosts
- Part 36: The Fans Were Loud, the CPUs Were Idle
- Part 37: A Power Trip, a Search Domain and a Wildcard
- Part 38: An Autonomous AI Pentester in a Sealed Box
- Part 39: Wasm Pods on k0s, and the Label That Kept Them Packed