Back to Home

Homelab Rebuild Part 7: Self-Hosted CI and Getting Back to IaC

| October 10, 2026 | 5 min to read

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.Monitor error on Proxmox VE 9 is a provider bug, not a permission problem. removed blocks with destroy = false dropped the old VMs from state without destroying seven live VMs that reused their VMIDs. Finally, import blocks brought the eight new VMs into bpg/proxmox 0.116.0 with 8 imported, 0 changed.

The Situation

  • GitHub org repo with Terraform Plan and Terraform Apply workflows that run on [self-hosted, linux, x64, homelab], runner group homelab
  • 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

  1. Read the plan, every line. A cleanup PR is exactly where a destroy hides.
  2. Reused IDs make state dangerous. Terraform binds resources by ID, not by intent.
  3. An error naming a permission isn’t always about permissions. Check whether the permission still exists.
  4. Import with import blocks in a PR. The plan shows what the import would change before anything happens.

Series

This post is part of Homelab Rebuild:

  1. Part 1: Upgrading Proxmox VE 8 to 9
  2. Part 2: Starting Over When the IaC Is Broken
  3. Part 3: When Tailscale Breaks Your Cilium Gateway
  4. Part 4: Living on the Tailnet
  5. Part 5: Secrets Without Secrets in Git
  6. Part 6: Certificates, DNS and a Private CA
  7. Part 7: Self-Hosted CI and Getting Back to IaC (this post)
  8. Part 8: Stateful Workloads on Unequal Hardware
  9. Part 9: Going Public Safely with Cloudflare Tunnel
  10. Part 10: When the Hypervisor Went Quiet
  11. Part 11: Hardening the Cluster in Five Layers
  12. Part 12: Observability with Coroot, and Losing pve-01
  13. Part 13: The Host That Kept Losing Power
  14. Part 14: Observability, Take Two with OpenTelemetry
  15. Part 15: Requests and Limits for Everything
  16. Part 16: Handing the Cluster to FluxCD
  17. Part 17: Making the Guardrails Earn Their Keep
  18. Part 18: When the API Server Outgrew Its VM
  19. Part 19: Logging In to OpenBao with Cloudflare Access and Google
  20. Part 20: Headlamp SSO, or Teaching kube-apiserver to Trust Cloudflare Access
  21. Part 21: pve-02 Loses Power, and Pi-hole Has Nowhere to Go
  22. Part 22: Turning Four Days of Fixes into AI Runbooks
  23. Part 23: microVM Sandboxes on Proxmox
  24. Part 24: Pi-hole Without a Home Host
  25. Part 25: It Was the Router
  26. Part 26: TLS on the Front, Plaintext Behind It
  27. Part 27: One Bad Power Adapter, Two Different Deaths
  28. Part 28: Observability, Take Three: Making It Worth Looking At
  29. Part 29: One Meter for Every Model, LiteLLM on Workers AI
  30. Part 30: Proxmox Joins the SSO, and a Plan Token Loses Admin
  31. Part 31: First Backups, Postgres, OpenBao and etcd to R2
  32. Part 32: Alerts That Reach Me, Even When the Lab Is Dead
  33. Part 33: The Firewall Held, the Tailnet Did Not
  34. Part 34: From Watching to Blocking, and a Record of Who Did What
  35. Part 35: Wazuh Without the 8 GB, Endpoint Security for One Mac and Three Hosts
  36. Part 36: The Fans Were Loud, the CPUs Were Idle
  37. Part 37: A Power Trip, a Search Domain and a Wildcard
  38. Part 38: An Autonomous AI Pentester in a Sealed Box
  39. Part 39: Wasm Pods on k0s, and the Label That Kept Them Packed

References