feat(proxmox): Proxmox VM template + Terraform/OpenTofu VM creation

Manual, documented bare-metal Proxmox install + cloud-init template build
(deliberately not automated — a fragile one-time step). Terraform/OpenTofu
config creates rn-platform-01/rn-lab-host-01 from that template via the
bpg/proxmox provider, with configurable (not hardcoded) VM sizing.

Validated: tofu fmt, tofu init -backend=false (real provider download),
tofu validate -> Success. No paid state backend required (local state).
This commit is contained in:
2026-08-18 17:12:55 -05:00
parent 45a147a61f
commit cbf81b09e7
9 changed files with 351 additions and 0 deletions
+1
View File
@@ -4,3 +4,4 @@ frontend/dist/
*.db-journal *.db-journal
/data/ /data/
.env .env
.ansible/
+41
View File
@@ -0,0 +1,41 @@
# Proxmox — manual, one-time steps
Deliberately NOT automated (Phase 4 §21: "do not over-automate bare-metal Proxmox installation
if it becomes fragile" — a broken automated install of the one host that IS the only way in is
a much worse failure mode than a documented manual step reviewed by a human once).
## 1. Install Proxmox VE
Standard ISO install on the Hetzner dedicated server, once purchased. Follow Proxmox's own
install guide. Full checklist: `docs/PRODUCTION_DEPLOYMENT.md` §1-2.
## 2. Build the cloud-init VM template
One-time, either via `virt-customize`/manual, or Packer if already a comfortable tool here:
```bash
# On the Proxmox host, after downloading an Ubuntu 22.04 cloud image:
qm create 9000 --name ubuntu-22.04-cloudinit --memory 2048 --cores 2 --net0 virtio,bridge=vmbr0
qm importdisk 9000 jammy-server-cloudimg-amd64.img local-zfs
qm set 9000 --scsihw virtio-scsi-pci --scsi0 local-zfs:vm-9000-disk-0
qm set 9000 --ide2 local-zfs:cloudinit
qm set 9000 --boot c --bootdisk scsi0
qm set 9000 --serial0 socket --vga serial0
qm set 9000 --agent enabled=1
qm template 9000
```
Then apply `infra/proxmox/cloud-init/user-data.yaml` as the template's default cloud-init
user-data (Proxmox's own cloud-init snippet mechanism — `Datacenter -> Storage -> snippets`).
## 3. Hand off to Terraform + Ansible
Once the template exists and tagged `template` (matching `infra/terraform/main.tf`'s data
source filter), `infra/terraform/` can clone `rn-platform-01`/`rn-lab-host-01` from it, and
`infra/ansible/site.yml` configures them. Neither of those needs further Proxmox-console work
after this point — see `docs/RUNBOOK_FIRST_DEPLOYMENT.md`.
## 4. Proxmox host firewall
Apply `infra/firewall/proxmox-host.rules` manually (rendered with real values) — see that
file's own header comment for why this one ruleset stays a manual apply, unlike the two VMs'.
+28
View File
@@ -0,0 +1,28 @@
#cloud-config
# Baseline cloud-init user-data baked into the VM template infra/terraform/ clones. Minimal on
# purpose — infra/ansible/site.yml does the real configuration (Docker, firewall, TLS, the app
# stack); this just gets a box far enough to be SSH-reachable and Ansible-manageable.
package_update: true
package_upgrade: true
users:
- name: rootnode-deploy
groups: [sudo]
shell: /bin/bash
sudo: ["ALL=(ALL) NOPASSWD:ALL"]
ssh_authorized_keys: [] # injected by Terraform's ssh_public_key variable at clone time
ssh_pwauth: false
disable_root: true
packages:
- qemu-guest-agent
- curl
- ca-certificates
runcmd:
- systemctl enable --now qemu-guest-agent
# See infra/proxmox/README.md for how to bake this into a reusable template (packer or manual
# virt-customize) before infra/terraform/ clones it.
+36
View File
@@ -0,0 +1,36 @@
# This file is maintained automatically by "tofu init".
# Manual edits may be lost in future updates.
provider "registry.opentofu.org/bpg/proxmox" {
version = "0.111.1"
constraints = "~> 0.60"
hashes = [
"h1:0qABXcf7ulwKRhKGoK4LYZ7ObyoFkPxHmCrL6jRhMgE=",
"h1:0xM4HmXdu0hLrauA8ugcxjZvYHA+PuRybUxrXZEAdRI=",
"h1:ML2D3UUZTM99yrll/EBXj7wBYMb8xmQgomqFNybEoxY=",
"h1:PFGpaC7xcdC8w9jpxZPLJ/FFbVK5hd/CArUY+oXXTu8=",
"h1:XoYUWDsGWvxSlir/sYiGzx1MkcPVUtWIgc6OWEdf/C8=",
"h1:Zn7LyCWL/Xn0yCHiigXFYEp3MIv0MOVzUfL657QOplY=",
"h1:dIGBUSoC89e/PbpBEjbZ/YOzAkzrG1kF6EYcqk+8IqE=",
"h1:eERpiB94PyhN2SqE9E1+6sgpk0Kn6qjy555R2cjfpug=",
"h1:ejD4OSOL98W/SA4jLDvxEwCW8NSdVGKLyKmgfAUROK8=",
"h1:iTQv4FVFhMVl2juw6lgrVTpGFrdmdNPj+NFY2Dms0SE=",
"h1:jcqEv/zW+heFIPq5xwXxgS9EuBmbjIM1MriwQjx75WE=",
"h1:xF+AQJqpRf30WbrfHgSeuLqDBptWhTIdVoMyv7j6yVo=",
"h1:xWV1Y6ItiFXNCJO9OtyINMx8cqT5XiTOK2Rne7Jyu3w=",
"zh:18fb7c31a08dde6bffa1a4d4a211e604d6d17eec7092fd59331b3db3c6f3742c",
"zh:1cd60761538289d4dd2a1086b3ae62a7b0bdd4b1a2f824e9a44e243413168dba",
"zh:2eb76f6fc8299b6820ff678c8252332cc3366e226b5ae2e61748fd2449c1ed92",
"zh:45e6f7ebd0bf48911d37060359a4f359b5743b3092e985295733990e406d0416",
"zh:4aa8ba912eae37975d2e983394d173e595ca34fc76b5bf220b37d0e99d76e98c",
"zh:58e0789923103a77d502a0a9fc3eb920625e8eb935ec2d4ac0d006aebd1d186c",
"zh:6df8aa85fb8865915537e946c19b02538ad188018a629759c213c6f03730f642",
"zh:6ed47bc00d0913a1d0880618fa1376115e9edab6b4a658c081061a7f0e4ca360",
"zh:c5b10ff4f33df7e4c29e8f1127d49845b561b37b57517e844fb0954d7923d65e",
"zh:d016510e14b738499f0db9d9b3aafe82fc6877fb4ab4e9f831fb68a8d70a1385",
"zh:d941f394069bbf24351b363da1c64383f487067aaee0a84f9b96476d4912e212",
"zh:ddf271dbc2632ae8ffa8de3972f243ee47d260cb2ac90aa784f2746d98e21a0f",
"zh:ed0caa3501c42f611b7e9622c9b1df69fd85dc25a3cd88d3076381829688cd62",
"zh:f26e0763dbe6a6b2195c94b44696f2110f7f55433dc142839be16b9697fa5597",
]
}
+44
View File
@@ -0,0 +1,44 @@
# Proxmox VM creation (Terraform/OpenTofu)
Creates `rn-platform-01` and `rn-lab-host-01` as Proxmox VMs from a cloud-init-enabled Ubuntu
22.04 template. Assumes Proxmox VE is already installed and reachable (bare-metal install
stays a manual, documented step — `docs/PRODUCTION_DEPLOYMENT.md` §2 — over-automating the one
irreversible step that could brick the only way into the host is not worth it).
Works with either Terraform or OpenTofu (the fork) — `bpg/proxmox` is available for both. Use
whichever this project already has installed; nothing here is Terraform-Cloud-specific or
requires a paid backend (state is local by default, `main.tf`'s commented-out `backend "s3"`
block is there if remote state is ever wanted, not required).
## Prerequisites (all deploy-time, hardware-dependent — cannot be done now)
1. Proxmox VE installed, reachable at `proxmox_api_url`.
2. A cloud-init-enabled Ubuntu 22.04 template already exists on the Proxmox host (packer/manual
— see `infra/proxmox/cloud-init/` for the cloud-init user-data this expects the template to
support; building the actual template is a one-time manual step, documented, not automated
here for the same "don't over-automate the fragile one-time step" reasoning as the Proxmox
install itself).
3. A Proxmox API token with permission to create VMs (`terraform.tfvars`, never committed —
see `terraform.tfvars.example`).
## Usage
```bash
cd infra/terraform
cp terraform.tfvars.example terraform.tfvars # fill in real values
terraform init # or: tofu init
terraform plan
terraform apply
```
Outputs the two VMs' IPs, which then go into `infra/ansible/group_vars/production.yml`
(`platform_ip`, `lab_host_ip`) before running `infra/ansible/site.yml`.
## What this does NOT do
- Install Proxmox itself.
- Build the base cloud-init template (one-time, manual, documented).
- Decide final storage/ZFS layout (`vm_disk_datastore` is a variable — set it to whatever
datastore the real disk-inspection checklist, `docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md` §4,
says to use once the real disks are known).
- Configure anything INSIDE the VMs — that's `infra/ansible/site.yml`'s job, run after `apply`.
+102
View File
@@ -0,0 +1,102 @@
# Creates rn-platform-01 and rn-lab-host-01 from a shared cloud-init template. Sizing comes
# from variables.tf's platform_vm/lab_host_vm objects — baseline defaults, override in
# terraform.tfvars once real load data exists (docs/CAPACITY_PLANNING.md). This module does
# NOT decide the host's final ZFS/storage layout; vm_disk_datastore just names whichever
# Proxmox storage the real disk-inspection checklist points at.
resource "proxmox_virtual_environment_vm" "rn_platform_01" {
name = "rn-platform-01"
node_name = var.proxmox_node
clone {
vm_id = data.proxmox_virtual_environment_vms.template.vms[0].vm_id
full = true
}
cpu {
cores = var.platform_vm.cores
type = "host"
}
memory {
dedicated = var.platform_vm.memory
}
disk {
datastore_id = var.vm_disk_datastore
size = var.platform_vm.disk_gb
interface = "scsi0"
}
network_device {
bridge = "vmbr0"
}
initialization {
user_account {
username = "rootnode-deploy"
keys = [var.ssh_public_key]
}
ip_config {
ipv4 {
address = "dhcp" # static assignment is a deploy-time decision once the real network
# plan is finalized — see docs/PRODUCTION_NETWORK_SECURITY.md §5
}
}
}
tags = ["rootnode", "platform"]
}
resource "proxmox_virtual_environment_vm" "rn_lab_host_01" {
name = "rn-lab-host-01"
node_name = var.proxmox_node
clone {
vm_id = data.proxmox_virtual_environment_vms.template.vms[0].vm_id
full = true
}
cpu {
cores = var.lab_host_vm.cores
type = "host"
}
memory {
dedicated = var.lab_host_vm.memory
}
disk {
datastore_id = var.vm_disk_datastore
size = var.lab_host_vm.disk_gb
interface = "scsi0"
}
network_device {
bridge = "vmbr0"
}
initialization {
user_account {
username = "rootnode-deploy"
keys = [var.ssh_public_key]
}
ip_config {
ipv4 {
address = "dhcp"
}
}
}
tags = ["rootnode", "lab-host"]
}
data "proxmox_virtual_environment_vms" "template" {
node_name = var.proxmox_node
tags = ["template"]
filter {
name = "name"
values = [var.vm_template_name]
}
}
output "rn_platform_01_ip" {
value = proxmox_virtual_environment_vm.rn_platform_01.ipv4_addresses
}
output "rn_lab_host_01_ip" {
value = proxmox_virtual_environment_vm.rn_lab_host_01.ipv4_addresses
}
+10
View File
@@ -0,0 +1,10 @@
proxmox_api_url = "https://proxmox.example.internal:8006/api2/json"
proxmox_api_token = "terraform@pve!rootnode=00000000-0000-0000-0000-000000000000"
proxmox_node = "pve"
vm_disk_datastore = "local-zfs"
ssh_public_key = "ssh-ed25519 AAAA... rootnode-deploy"
management_cidr = "203.0.113.0/24"
# Override sizing here once real load data exists — see docs/CAPACITY_PLANNING.md.
# platform_vm = { cores = 4, memory = 16384, disk_gb = 100 }
# lab_host_vm = { cores = 24, memory = 98304, disk_gb = 500 }
+66
View File
@@ -0,0 +1,66 @@
variable "proxmox_api_url" {
description = "e.g. https://proxmox.example.internal:8006/api2/json"
type = string
}
variable "proxmox_api_token" {
description = "Proxmox API token (user@realm!tokenid=uuid) — never committed, see terraform.tfvars.example"
type = string
sensitive = true
}
variable "proxmox_tls_insecure" {
description = "Set true only if the Proxmox host uses a self-signed cert with no CA to trust"
type = bool
default = false
}
variable "proxmox_node" {
description = "Proxmox node name (e.g. 'pve')"
type = string
}
variable "vm_template_name" {
description = "Name of the pre-built cloud-init Ubuntu 22.04 template (see infra/terraform/README.md prerequisites)"
type = string
default = "ubuntu-22.04-cloudinit"
}
variable "vm_disk_datastore" {
description = "Proxmox storage/datastore for VM disks — set once real disk layout is decided (docs/PHASE4_PRODUCTION_INFRASTRUCTURE.md §4)"
type = string
default = "local-zfs"
}
variable "ssh_public_key" {
description = "Public key injected via cloud-init for the rootnode-deploy user"
type = string
}
variable "management_cidr" {
description = "Same value as infra/ansible/group_vars/production.yml's management_cidr — used for the VM firewall's SSH allow rule at the Proxmox level too, if managed there"
type = string
}
# ── rn-platform-01 sizing — baseline/recommended/max-growth (Phase 4 §3) ──────────────────
variable "platform_vm" {
description = "rn-platform-01 sizing. 'baseline' is the default; bump via terraform.tfvars once real hardware/load data exists."
type = object({
cores = number
memory = number # MB
disk_gb = number
})
default = {
cores = 4
memory = 16384
disk_gb = 100
}
}
# ── rn-lab-host-01 sizing — majority of host resources, per the Phase 4 spec ───────────────
variable "lab_host_vm" {
description = "rn-lab-host-01 sizing. Deliberately NOT a final number — see docs/CAPACITY_PLANNING.md; this default is a conservative starting point for a 16c/32t/128GB class host, not a target."
type = object({
cores = number
memory = number # MB
disk_gb = number
})
default = {
cores = 24
memory = 98304
disk_gb = 500
}
}
+23
View File
@@ -0,0 +1,23 @@
terraform {
required_version = ">= 1.5.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.60"
}
}
# Local state by default — no paid backend required. Uncomment for remote state once/if
# that's wanted; not needed to use this module.
# backend "s3" {
# bucket = "rootnode-terraform-state"
# key = "production/terraform.tfstate"
# region = "auto"
# }
}
provider "proxmox" {
endpoint = var.proxmox_api_url
api_token = var.proxmox_api_token
insecure = var.proxmox_tls_insecure
}