Without triggers, commands cached in Pulumi state don't re-run when VMs are deleted and recreated with new IDs. This caused stale state where start/install commands were skipped while the new VMs were never bootstrapped, leading to SSH "no route to host" failures. All command resources now carry triggers: [vmId] so they are automatically replaced (and re-run) whenever the underlying VM changes. Also adds --node-name to every k3s install/join command so nodes register under the expected name (k3s-master-1 etc.) regardless of the VM's actual hostname, which cloud-init does not set explicitly.
Homelab Infrastructure as Code
A Pulumi-based IaC template for managing a Proxmox homelab. The goal is to replace manual GUI configuration and ad-hoc YAML stacks (LXC, VM, Docker, etc.) with version-controlled, reproducible infrastructure — starting with a highly available k3s cluster across multiple Proxmox nodes.
This repo is intentionally abstract: credentials are never hardcoded, making it easy to fork and adapt as a template for your own homelab.
Why IaC for a homelab?
- Reproducibility — rebuild your entire environment from scratch with a single command
- Version history — every change is tracked in git; roll back at any time
- Auditability — diff infrastructure changes before applying them (
pulumi preview) - Automation — CI/CD handles deploys; no manual SSH into nodes
- Portability — swap node names, datastores, or credentials without touching logic
Repository layout
.
├── proxmox-infra/ # Pulumi TypeScript stack — VMs & LXC on Proxmox
│ ├── index.ts # All Pulumi resources
│ ├── Pulumi.yaml # Stack project definition
│ └── Pulumi.dev.yaml # Encrypted stack config (gitignored)
├── .gitea/
│ └── workflows/
│ └── deploy-proxmox-infra.yaml # Gitea Actions CI/CD pipeline
Current stack: proxmox-infra
Provisions a 5-node k3s cluster spread across two Proxmox hosts (pve and pve-bckp, third bare metal host to be added later for actual parity):
| VM name | Role | Proxmox node |
|---|---|---|
| k3s-master-1 | master | pve |
| k3s-master-2 | master | pve |
| k3s-worker-1 | worker | pve |
| k3s-master-3 | master | pve-bckp |
| k3s-worker-2 | worker | pve-bckp |
Each node is a full clone of an Ubuntu Noble (24.04) cloud-image template, with cloud-init injecting hostname, user credentials, and SSH key at boot.
Tech stack:
- Pulumi with TypeScript
@muhlba91/pulumi-proxmoxvev8.x community provider- Self-hosted Pulumi state backend (PostgreSQL)
- Gitea Actions for CI/CD
Prerequisites
- Pulumi CLI installed
- Node.js 18+ and npm
- Access to a Proxmox node with an API token
- A self-hosted Pulumi state backend (PostgreSQL connection string)
- Gitea instance for CI/CD (optional for local use)
Getting started
1. Clone and install
git clone <your-repo-url>
cd proxmox-infra
npm install
2. Configure credentials
All secrets are stored as encrypted Pulumi config values — never in plain environment variables or committed files.
# Set Proxmox API credentials
pulumi config set --secret pve1Endpoint https://<proxmox-host-1>:8006
pulumi config set --secret pve1ApiToken <user>@pam!<token-id>=<uuid>
pulumi config set --secret pve2Endpoint https://<proxmox-host-2>:8006
pulumi config set --secret pve2ApiToken <user>@pam!<token-id>=<uuid>
# Set VM credentials
pulumi config set --secret k3sVmPassword <vm-password>
pulumi config set --secret sshPvePublicKey "ssh-ed25519 AAAA..."
Pulumi encrypts these values into Pulumi.dev.yaml using your PULUMI_CONFIG_PASSPHRASE.
3. Set the state backend
export PULUMI_BACKEND_URL=postgresql://<user>:<pass>@<host>/<db>
export PULUMI_CONFIG_PASSPHRASE=<your-passphrase>
4. Preview and deploy
# See what will change before touching anything
pulumi preview
# Sync Pulumi state with actual Proxmox state (run after any manual GUI changes)
pulumi refresh --yes
# Deploy
pulumi refresh --yes && pulumi up --yes
CI/CD (Gitea Actions)
The workflow at .gitea/workflows/deploy-proxmox-infra.yaml runs automatically:
| Event | Action |
|---|---|
Pull request → main |
pulumi preview (read-only) |
Push to main |
pulumi refresh + pulumi up |
| Manual trigger | pulumi refresh + pulumi up |
Required Gitea secrets
Configure these under Settings → Actions → Secrets in your Gitea repo:
| Secret | Description |
|---|---|
PULUMI_BACKEND_URL |
PostgreSQL connection string for the state backend |
PULUMI_CONFIG_PASSPHRASE |
Passphrase to decrypt secrets in Pulumi.dev.yaml |
PULUMI_DEV_YAML |
Base64-encoded content of Pulumi.dev.yaml |
Pulumi.dev.yaml is gitignored because it contains your encryption salt. Whenever it changes (e.g. after adding or rotating a secret), re-encode it and paste the output into the Gitea secret:
base64 -w 0 proxmox-infra/Pulumi.dev.yaml
Adapting this as a template
- Fork or copy the repo
- Update node names (
pve,pve-bckp) and datastore IDs inindex.tsto match your setup - Add or remove VMs from the
nodeConfigsarray - Set your own secrets with
pulumi config set --secret - Point the CI/CD workflow at your own Git instance
Roadmap
- LXC container management
- Docker / Compose stack provisioning
- Network and firewall rules
- Automated k3s bootstrapping (kubeconfig export)
- Additional worker nodes and storage volumes
- Migrate secrets management to OpenBao — replace
PULUMI_CONFIG_PASSPHRASEand manualPulumi.dev.yamlencoding with a self-hosted vault - Add a third bare metal proxmox instance to create an actual 3 node parity.