
In the last post I laid out the plan for rebuilding my homelab as a small enterprise-style datacenter: five pillars, eight milestones, one blog post per milestone. Milestone 1 is the foundation everything else stands on — three physical boxes turned into a single Proxmox VE cluster, plus the Terraform module that provisions every VM on top of it from now on. This post covers both.
🎯 Goal of this milestone
Two things had to be true before I could touch Active Directory, GitLab, or Kubernetes:
- Three separate HP machines had to become one Proxmox VE cluster, so VMs can be managed, migrated, and backed up from a single pane of glass.
- No VM should ever be clicked into existence again. Every VM — from the domain controller to the Kubernetes control plane — gets created by
terraform apply, not by a wizard in the Proxmox UI.
Everything below reflects the cluster as it actually runs today — I pulled the live values straight from the Proxmox API instead of relying on install-day notes.
🖥️ Hardware
Three HP machines, deliberately non-identical — this is recycled hardware, not a matched set:
| Host | RAM | Storage | Management IP |
|---|---|---|---|
| HP1 | 64 GB | 512 GB OS SSD + 2 TB SSD | 192.168.10.30 |
| HP2 | 32 GB | 512 GB OS SSD + 2 TB SSD | 192.168.10.40 |
| HP3 | 32 GB | 512 GB OS SSD + 3×2 TB (ZFS RAIDZ1) | 192.168.10.50 |
💽 Installing Proxmox VE
Nothing fancy on the install itself: Proxmox VE 9.2 ISO, written to a USB stick with Rufus, one stick per machine, boot, install, repeat three times. No remote virtual media, no PXE — for three boxes sitting on the same desk, a USB stick is still the fastest path from zero to a running hypervisor.
Each node got a static IP on the 192.168.10.0/24 management network during install (.30, .40, .50 for HP1–HP3), which doubles as the network the cluster and every VM lives on — more on that trade-off below.
🌐 Networking
Each node has a single NIC bridged as vmbr0 (Linux bridge, STP off), which is what every VM’s network_device attaches to later on. A second onboard NIC port exists on the boards but sits unused for now — a candidate for a dedicated storage or corosync network later, but not something a 3-node homelab cluster needs on day one.
That’s also the one deliberate simplification worth calling out: corosync (the cluster’s heartbeat/quorum protocol) runs over the same network as management and VM traffic, using a single ring:
ring0_addr: HP1 = 192.168.10.30
ring0_addr: HP2 = 192.168.10.40
ring0_addr: HP3 = 192.168.10.50In a production cluster you’d isolate corosync on its own VLAN or NICs so a saturated management network can’t cause spurious quorum loss. For three nodes on a home network with no other traffic competing for bandwidth, that risk is acceptable — it’s on the list of things to revisit if this ever grows past a homelab.
🔗 Forming the cluster
Standard pvecm flow: create the cluster on HP1, then join HP2 and HP3 to it. With three voting members the cluster tolerates exactly one node going offline (2 of 3 votes) before it loses quorum — the minimum viable HA setup, and the reason for going with three nodes instead of two in the first place. Querying the cluster today confirms the happy path held:
$ pvecm status
Cluster name: homelab
Quorate: Yes
Nodes: 3
Nodeid Ip Name
1 192.168.10.30 HP1 (local)
2 192.168.10.40 HP2
3 192.168.10.50 HP3🗄️ Storage layout
This is where the three nodes stop being identical. Each node’s second disk (or disks, for HP3) is set up differently on purpose:
- HP1 & HP2: the extra 2 TB SSD is added as plain directory storage (
data/data-hp2) — no ZFS, no RAID, just a filesystem for VM disks, ISOs, and backups. Single SSD, nothing to mirror against, so the simplest option wins. - HP3: three 2 TB disks go into a ZFS pool run as RAIDZ1 (
data-hp3), giving just under 4 TB usable with one-disk fault tolerance. This is the node earmarked for anything I don’t want to lose to a single dead drive — think Longhorn replicas or MinIO data once Kubernetes lands.
Every node also keeps its default local (root filesystem) and local-lvm (LVM-thin on the OS SSD) storage from the installer, used for the OS itself and as overflow.
🧱 From ClickOps to Infrastructure as Code
With the cluster up, the tempting next step is to click “Create VM” seven times in the Proxmox UI. I did exactly that in the previous version of this homelab, and every VM ended up slightly different — a forgotten cloud-init setting here, a manually-typed static IP there, no record of what I’d actually done six months later. Not this time: every VM in this rebuild is defined once, in Terraform, and reproducible from a clean cluster.
⚙️ The Terraform module
The module lives in 01_terraform/ in the project repo and uses the bpg/proxmox provider. Rather than mounting an Ubuntu Server ISO and clicking through the installer, every VM is cloned from an Ubuntu 26.04 cloud image and configured entirely through cloud-init — static IP, DNS, SSH key, all of it — so there’s no interactive install step anywhere in the pipeline:
resource "proxmox_download_file" "ubuntu_cloud_image" {
content_type = "iso"
datastore_id = var.image_datastore_id
node_name = var.proxmox_node
url = var.ubuntu_cloud_image_url
file_name = "noble-server-cloudimg-amd64.img"
overwrite = false
}
resource "proxmox_virtual_environment_vm" "vm" {
for_each = local.vms
name = each.key
tags = ["terraform", "rebuild-2026"]
node_name = var.proxmox_node
vm_id = each.value.vmid
disk {
datastore_id = var.vm_datastore_id
file_id = proxmox_download_file.ubuntu_cloud_image.id
interface = "scsi0"
size = each.value.disk_gb
}
initialization {
ip_config {
ipv4 {
address = "${each.value.ip}/${var.network_prefix}"
gateway = var.network_gateway
}
}
user_account {
username = var.vm_username
keys = var.ssh_public_keys
}
}
}All seven VMs — one per role for this milestone — are declared as a single locals.vms map, so adding an eighth VM later is a five-line diff, not a copy-pasted resource block:
| Name | Role | IP | RAM | Disk |
|---|---|---|---|---|
| HP1-dc01 | AD / DNS / Root CA | 192.168.10.31 | 4 GB | 60 GB |
| HP1-gitlab01 | GitLab / Container Registry | 192.168.10.32 | 8 GB | 120 GB |
| HP1-seed01 | Dev VM (Ansible, kubectl, tooling) | 192.168.10.33 | 4 GB | 60 GB |
| HP1-vault01 | Secrets management (phase 2) | 192.168.10.34 | 2 GB | 40 GB |
| HP1-icinga01 | Monitoring | 192.168.10.35 | 4 GB | 60 GB |
| HP1-k8s-cp01 | RKE2 control plane | 192.168.10.36 | 8 GB | 80 GB |
| HP1-k8s-cp02 | RKE2 control plane | 192.168.10.37 | 8 GB | 80 GB |
VM IDs follow 100 + <last IP octet> (131–137) — Proxmox requires IDs ≥ 100, and this way the ID and the address stay easy to correlate at a glance, without a separate lookup table.
▶️ Rolling it out
The whole rollout is three commands once a Proxmox API token and an SSH key are in place:
cp terraform.tfvars.example terraform.tfvars
# edit terraform.tfvars: API URL, API token, node name, SSH key
terraform init
terraform plan
terraform applyA couple of minutes later, all seven VMs exist, boot, and answer on their assigned IPs — and Terraform tells you exactly which is which:
Outputs:
vm_ids = {
"HP1-dc01" = 131
"HP1-gitlab01" = 132
"HP1-icinga01" = 135
"HP1-k8s-cp01" = 136
"HP1-k8s-cp02" = 137
"HP1-seed01" = 133
"HP1-vault01" = 134
}
vm_ips = {
"HP1-dc01" = "192.168.10.31"
"HP1-gitlab01" = "192.168.10.32"
...
}🧩 A few things worth flagging
- Cloud image, not ISO. The Ubuntu ISO sitting on HP1’s local storage is unused by this module on purpose — cloning a cloud image and configuring it via cloud-init is faster and fully unattended. The ISO stays around only for the rare manual install.
ignore_changeson the disk’sfile_id. Without it, Terraform wants to re-import the base image on every plan, since the downloaded file’s ID isn’t meant to be reconciled after the initial clone.- Secrets never leave the internal repo.
terraform.tfvarsholds the real API token and SSH key and is gitignored; a pre-push hook actively blocks it from ever reaching the public GitHub mirror of this project, on top of the gitignore. - State is local, and that’s fine. One operator, one laptop running
apply— a remote backend would be solving a problem I don’t have yet.
🔜 What’s next
With a quorate 3-node cluster and a repeatable way to spin up VMs, milestone 2 starts turning HP1-dc01 into an actual domain controller: Active Directory, DNS, and the offline Root CA that everything else in this homelab will eventually trust. That’s the next post.






