Terraform Setup — PVE02
Overview
Terraform manages the infrastructure layer of pve02 — creating, modifying, and tracking LXC containers and VMs as code. It uses the bpg/proxmox provider to talk to the Proxmox API.
Terraform runs on pve02 from /root/ansible/terraform/proxmox/. The configuration lives in the same git repository as Ansible.
Division of responsibilities:
| Tool | Handles |
|---|---|
| Terraform | Container/VM creation, resources (CPU/RAM/disk), network config, tags |
| Ansible | OS configuration, packages, SSH, users, security, monitoring, service deployment |
Source of Truth — Ansible Inventory
The Ansible inventory is the single source of truth for hostnames, IPs, VMIDs, and LXC specs. Terraform reads directly from it — there is no duplication between Ansible and Terraform config.
| File | Purpose |
|---|---|
inventory/hosts.yml | Hostname, IP (ansible_host), VMID per host |
inventory/group_vars/{zone}.yml | VLAN ID and gateway per zone |
inventory/host_vars/{host}/vars.yml | Per-host LXC spec (lxc_cores, lxc_memory, etc.) + Ansible vars |
A host is automatically Terraform-managed as soon as lxc_cores is present in its host_vars. No separate Terraform resource block needed.
LXC spec keys in host_vars
# Required
lxc_description: "Human-readable description"
lxc_tags: ["zone", "role"]
lxc_cores: 2
lxc_memory: 2048 # MB
lxc_swap: 512 # MB
lxc_disk: 20 # GB
# Optional
lxc_started: false # default: true
lxc_unprivileged: false # default: true
lxc_keyctl: true # default: false — needed for e.g. synapse
Directory Structure
/root/ansible/terraform/proxmox/
├── provider.tf # bpg/proxmox provider and version lock
├── variables.tf # Input variable definitions
├── terraform.tfvars # Non-secret variable values (committed)
├── secrets.tfvars # API token + SSH keys — gitignored, stays on pve02 only
├── outputs.tf # IP address summary output (reads from inventory)
├── lxc_managed.tf # for_each resource — all standard LXC containers
├── lxc_infra.tf # MGMT zone special-case containers (pbs01, unifi)
├── lxc_services.tf # SERVERS zone special-case containers (podman, copyparty, paperless)
├── lxc_gaming.tf # DMZ zone — placeholder (all DMZ hosts use lxc_managed.tf)
├── lxc_authentik.tf # Authentik — placeholder (uses lxc_managed.tf)
└── vm_automation.tf # HOMELAB zone VMs (haos)
lxc_managed.tf — how it works
Reads the inventory and resolves the full config dynamically:
hosts.yml → vmid, ansible_host (IP)
group_vars/{zone} → vlan_id, vlan_gateway
host_vars/{host} → lxc_cores, lxc_memory, lxc_disk, lxc_tags, ...
Only hosts with lxc_cores in their host_vars appear in the for_each — the rest are ignored automatically.
Managed Resources
Standard (via lxc_managed.tf — proxmox_virtual_environment_container.lxc["{host}"])
| Host | VMID | Zone | IP |
|---|---|---|---|
| authentik | 268 | SERVERS | 10.69.20.68 |
| pulse | 260 | SERVERS | 10.69.20.60 |
| uptime-kuma | 275 | SERVERS | 10.69.20.75 |
| onedev | 276 | SERVERS | 10.69.20.76 |
| hookshot | 740 | DMZ | 10.69.70.40 |
| mumble | 710 | DMZ | 10.69.70.10 |
| minecraft | 720 | DMZ | 10.69.70.20 |
| synapse | 730 | DMZ | 10.69.70.30 |
Special-case (standalone resources — mount points, GPU, idmaps, or DHCP)
| Resource | VMID | Zone | IP | Reason |
|---|---|---|---|---|
| pbs01 | 200 | MGMT | 10.69.10.25 | Untagged network, special mount |
| unifi | 115 | MGMT | 10.69.10.15 (DHCP) | No static IP yet |
| podman | 100 | SERVERS | 10.69.20.10 | GPU passthrough, privileged |
| copyparty | 220 | SERVERS | 10.69.20.20 | HDD mount point |
| paperless | 272 | SERVERS | 10.69.20.72 | idmaps, HDD mount, firewall |
| haos | 101 | HOMELAB | DHCP | VM — Home Assistant OS |
VMID naming scheme: encodes VLAN + IP — e.g. 720 = VLAN 70, IP .20. Use this convention for new resources.
Authentication
Terraform authenticates to the Proxmox API using a dedicated token:
User: terraform@pve
Role: PVEAdmin
Token: terraform@pve!terraform=<secret>
The token secret and SSH public keys are stored in secrets.tfvars (gitignored — never committed):
proxmox_api_token = "terraform@pve!terraform=YOUR-SECRET-HERE"
ssh_public_keys = [
"ssh-ed25519 AAAA... user@host",
]
SSH keys are injected into new containers at creation time via initialization.user_account.keys. Since initialization is in ignore_changes, this only applies on first creation — existing containers are not affected.
Note: The terraform@pve token cannot modify feature flags on privileged containers — that requires root@pam. Affected resources (pbs01, podman) have features in their lifecycle.ignore_changes.
Recreating the token
If the token is lost:
pveum user token remove terraform@pve terraform
pveum user token add terraform@pve terraform --privsep=0
# Update secrets.tfvars with the new secret
Common Operations
Check current state
cd ~/ansible/terraform/proxmox
terraform plan -var-file="secrets.tfvars"
Should show no changes when everything matches. If it shows changes, review carefully before applying.
Apply changes
terraform apply -var-file="secrets.tfvars"
Show current state
terraform state list
terraform show
Show IP summary
terraform output
Adding a New Container
-
Add to inventory —
inventory/hosts.ymlunder the correct zone group:servers:hosts:myservice:ansible_host: 10.69.20.77vmid: 277 -
Create host_vars —
inventory/host_vars/myservice/vars.ymlwithlxc_*keys:lxc_description: "My new service"lxc_tags: ["servers", "myservice"]lxc_cores: 2lxc_memory: 1024lxc_swap: 256lxc_disk: 10 -
Plan and apply:
terraform plan -var-file="secrets.tfvars"terraform apply -var-file="secrets.tfvars" -
Bootstrap with Ansible:
ansible-playbook playbooks/bootstrap.yml -l myservice -kansible-playbook playbooks/site.yml -l myservice
That's it — no Terraform resource block needed. The lxc_managed.tf for_each picks it up automatically.
Special-case Containers
Containers that can't use the generic for_each (GPU passthrough, idmaps, mount points, DHCP) have their own standalone resource blocks in the respective lxc_*.tf file. When adding such a container, write a resource block manually following the existing patterns in lxc_services.tf or lxc_infra.tf.
Lifecycle Notes
Most resources use lifecycle.ignore_changes for fields that are either:
- Managed externally (community scripts set IP/hostname at creation)
- Proxmox-internal (console settings, OS template reference)
- Permission-restricted (features on privileged containers)
lifecycle {
ignore_changes = [initialization, operating_system, console]
}
This means Terraform tracks the resource exists and manages its core config (CPU, RAM, disk, network, tags) but won't fight with Proxmox over runtime details.
Moving Resources to the for_each
If you need to bring an existing standalone resource into lxc_managed.tf (after adding lxc_* keys to its host_vars), move the Terraform state first to avoid destroy/recreate:
terraform state mv proxmox_virtual_environment_container.myresource \
'proxmox_virtual_environment_container.lxc["myhost"]'
Then remove the standalone resource block and run terraform plan to verify no changes.
Git Workflow
Terraform config is part of the same repo as Ansible:
# Local machine — edit files, then push
git add inventory/ terraform/
git commit -m "add myservice"
git push
# pve02 — pull and apply
cd ~/ansible && git pull
cd terraform/proxmox
terraform plan -var-file="secrets.tfvars"
terraform apply -var-file="secrets.tfvars"
secrets.tfvars is gitignored and only exists on pve02. Never commit it.
Storage Reference
| Datastore | Type | Used for |
|---|---|---|
| SSD-Storage | ZFS | rootfs for all containers and VMs |
| HDD | ZFS | Media, large data, PBS backup volume |
| local | dir | ISO images, LXC templates |
| pbs-backup-local | PBS | Backup target via pbs01 |