On this page
In part one I argued that codifying a running homelab means describing what exists rather than building from scratch, and that the tool should hold a read-only credential so it physically cannot damage anything. This post is the part where that becomes real: nine containers that had been running for months, brought under management without one of them noticing.
The finish line is a single line of output — No changes. Your infrastructure matches the configuration. — and getting there took considerably longer than I expected, because “matches” turns out to be a strict word.
Every value below is a stand-in. 10.0.0.73 is a documentation address — use your own node’s address. pvelab01 through pvelab04 are my node names; yours will differ. Container IDs like 100 and names like wikijs are mine — use the ones your cluster actually has. terraform@pve!tf is the token identity created in part one, and its secret belongs in your own secret store, never in a committed file. If a value looks specific to one machine, it’s a placeholder to change — not a literal to copy.
The shape of the job
Four steps, and the third is where the time goes.
Step 1: point the provider at your cluster
I’m using OpenTofu with the bpg/proxmox provider, which at the time of writing is at v0.111.1.
A word on the version constraint, because it promises less than it looks like it does. The ~> operator allows the rightmost component to increment, so ~> 0.60 accepts anything from 0.60 up to (not including) 1.0 — which is every release this provider has ever made. On a project still below 1.0 there is no stability guarantee across minor versions, so that constraint is a floor, not a shield. The thing actually pinning your builds is the generated .terraform.lock.hcl file, which records the exact version and its checksums. Commit it.
terraform {
required_version = ">= 1.8"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.60"
}
}
}
provider "proxmox" {
endpoint = "https://10.0.0.73:8006"
# Self-signed cluster certificate on a LAN-only API.
insecure = true
# Auth comes from the PROXMOX_VE_API_TOKEN environment variable —
# never write the token secret into a file you commit.
}
The credential stays out of the file entirely. The provider reads it from an environment variable, which I source from a secret file at the moment I run a command — the same secrets discipline that applies everywhere else.
plan:
@bash -c 'set -a; source ~/.my-secrets; set +a; cd terraform && tofu plan'
Step 2: declare what you want to import
An import block says “this resource address should correspond to that existing object.” It contains no settings — just the mapping.
The identifier format is provider-specific. For containers under this provider, the official documentation shows it as node_name/vm_id — the node the container lives on, a slash, then its numeric ID.
import {
to = proxmox_virtual_environment_container.wikijs
id = "pvelab03/106"
}
import {
to = proxmox_virtual_environment_container.monitoring
id = "pvelab02/105"
}
I deliberately did not import one container — a throwaway I use for screenshots that gets rebuilt constantly. Codifying something whose whole purpose is to be disposable adds noise to every plan for no benefit. Managing everything is not the goal; managing the things whose loss would hurt is.
Step 3: generate a draft, then curate it
You could write each resource block by hand from the settings shown in the Proxmox web interface. Don’t — there are far more attributes than the interface displays, and missing one produces a plan that wants to “fix” it.
Instead let the tool write a first draft. OpenTofu will generate configuration for any resource named in an import block that doesn’t yet exist in your files:
tofu plan -generate-config-out=generated.tf
Now the important part, and the reason this step gets its own section. The documentation is refreshingly blunt about what you get: OpenTofu produces HCL containing its “best guess at the appropriate value for each resource argument”, recommends “iterating to find your ideal configuration by removing some attributes, adjusting the value of others,” and warns that for complex resources it may generate “conflicting resource arguments” where “you must choose only one.”
That is exactly what happened. The generated file for my containers included attributes that were invalid on their face — an empty entrypoint string, a numeric field set to zero where the provider expects it unset, and a vm_id of null on resources whose ID was the one thing I definitely knew. None of it validated until I removed them.
Generated configuration is a typing shortcut, not an authority. It is produced by inspecting the object through the provider’s schema, so anything the schema models awkwardly comes out awkward. Curate it into something you’d be willing to write yourself, because in six months you will be reading it as though you did.
The two differences you can’t fix by editing
After clearing the invalid attributes I still had a plan that wanted to change things — and neither difference was a real one.
Provider-injected defaults. The provider adds its own operation timeouts to every resource. They aren’t settings on your container; they’re the provider’s local behaviour, and they’ll never match a configuration that doesn’t mention them.
Normalised values. One container’s description differed from the live value by trailing whitespace the provider had trimmed. Editing the real container to add the whitespace back would be absurd.
The right instrument for both is ignore_changes, which tells the tool not to track specific attributes. The documentation notes that “only attributes defined by the resource type can be ignored”, so this is a scalpel, not a blanket.
resource "proxmox_virtual_environment_container" "wikijs" {
lifecycle {
prevent_destroy = true
# timeout_* are provider-local defaults injected at plan time;
# ignoring them keeps the import pure. The description differs
# only by whitespace the provider trims.
ignore_changes = [
timeout_clone, timeout_create, timeout_delete,
timeout_start, timeout_update, description,
]
}
node_name = "pvelab03"
started = true
unprivileged = true
cpu {
cores = 2
}
memory {
dedicated = 4096
}
disk {
datastore_id = "local-lvm"
size = 32
}
initialization {
hostname = "wikijs"
ip_config {
ipv4 {
address = "10.0.0.76/24"
gateway = "10.0.0.1"
}
}
}
network_interface {
bridge = "vmbr0"
name = "eth0"
}
}
Note prevent_destroy = true sitting alongside. Every imported production resource gets it — as part one explains, it’s the second lock behind the read-only token, and it’s second for a specific reason.
ignore_changes is the correct tool for a difference that isn’t real. It is the wrong tool for a difference that is. If the plan says a container has 8 GB and your code says 4 GB, one of them is wrong and you need to find out which — silencing it hides a genuine drift and turns your drift detector into decoration.
Step 4: apply, and watch nothing happen
Once the plan shows only imports and no modifications, applying it writes the containers into state. Nothing is sent to the cluster — which you can prove, because the read-only token could not have written anything even if the plan had asked it to.
This is where the safety posture pays off concretely: the operation that feels riskiest is the one your credentials make impossible to get wrong.
tofu apply
Then the moment the whole exercise exists for:
$ tofu plan
No changes. Your infrastructure matches the configuration.
Turning a clean plan into a drift detector
A clean plan you have to read yourself is a nice feeling. A clean plan a machine can check is a monitor.
plan accepts a -detailed-exitcode flag that, in the documentation’s terms, changes the exit codes to describe what the plan contains: 0 for an empty diff, 1 for an error, 2 for a non-empty diff. That’s the whole mechanism — a scheduled run can now tell the difference between “nothing changed,” “something changed,” and “I couldn’t check.”
tofu plan -detailed-exitcode
# 0 = no changes 1 = error 2 = drift detected
If you save a plan with -out, the documentation warns it may contain sensitive data in cleartext unless plan encryption is enabled — treat the file as a secret and delete it when done. It also cautions against naming it with a .tf or .tofu suffix, because the tool would then try to parse your plan as configuration.
Run that on a schedule and you get an answer to a question that’s otherwise very hard to ask: has anyone changed my cluster without writing it down? In a lab where you’re the only administrator, “anyone” usually means you, three months ago, at eleven at night. That’s exactly the change most worth catching, because it’s the one you’ll have completely forgotten by the time it matters — the same class of problem as a monitor that’s been quietly wrong for a day.
What this doesn’t give you
Worth restating, because a clean plan is seductive.
The state file now describes nine containers precisely. It says nothing about what’s inside them — that remains entirely the business of your backups. And the import applies were state-only operations, so nothing here has been tested by an actual create. A description that has never been used to build anything is a hypothesis, and proving it is a separate exercise: creating a real container from this code, configuring it, and destroying it again. That’s part four of the series.
Part three is the other half of the description — the hosts themselves, and the trick of writing configuration management that asserts what your nodes already do rather than imposing something new on them.
Related posts:
- Turn a Hand-Built Proxmox Cluster Into Code Without Breaking It — part one: why import-first, and the read-only credential that makes it safe
- Proxmox Backup Server: Automated CT and VM Backups with Deduplication — the contents half of recovery, which this code deliberately does not cover
- Homelab Capacity Planning: What If a Node Dies Tonight? — the other read-only tool that reasons about your cluster without touching it
- Forming a Proxmox Cluster: Quorum, Corosync, and Joining Multiple Nodes — the multi-node setup being imported here
- Creating Your First Proxmox LXC Container: Step-by-Step — what these resource blocks are describing, done by hand
- Never Commit a Secret to Your Knowledge Base — where the API token secret lives instead of in your repo
- The Docker localhost Trap: 24 Hours of Red, Zero Real Downtime — what happens when nothing tells you a check has been wrong for a day
Comments
Comments are powered by GitHub Discussions — sign in with a GitHub account to join the conversation.