Terraform
Stop rebuilding infrastructure from memory at 11pm. Declare what should exist, let Terraform figure out the steps, and never again wonder which box you hand-tweaked at midnight.
The Desired-State Mindset
It's 11pm. Your demo environment needs one more node for tomorrow's call, so you click through the hypervisor UI from muscle memory — four vCPUs, 8 gigs of RAM, attach the ISO, next, next, finish. It works. Three weeks later you rebuild it and spend Saturday wondering why the new one can't join the cluster. The answer is a sysctl tweak you made by hand at midnight and never wrote down.
That gap — between the infrastructure you meant to build and the infrastructure you actually built — is the entire reason Terraform exists. Every hand-clicked setting is a decision that lives only in your head. Terraform moves those decisions into files: versioned, reviewable, repeatable. Documentation that executes.
A promise, not a script
A script says do these steps in this order. Terraform says this is what should exist — and computes the steps itself. You declare the end state; Terraform compares it against reality and works out the shortest path between the two. Run it once and it builds three VMs. Run it again with nothing changed and it does nothing. That property has a name — idempotence — and it's the difference between automation you trust and automation you babysit.
One analogy, then the real mechanics: Terraform is a thermostat, not a timer on a space heater. You set 72 degrees (the desired state). The thermostat checks the actual room temperature and decides whether to heat, cool, or sit still. Terraform's "room temperature" is the state file — its record of what currently exists, which you'll meet properly in chapter 2. The provider plugins are the furnace and AC: they translate Terraform's decisions into real API calls against Proxmox, AWS, Cloudflare, whatever you point them at.
Why a presales SE or homelabber should care
Three reasons, in order of how fast they pay off. Repeatability: rebuild a demo lab or a dead node exactly, months later, from the same files. Reviewability: before anything changes, Terraform shows you a diff of what it's about to do — you get to approve infrastructure changes the way you approve code changes. Portability of knowledge: the .tf files are the runbook. New teammate, new you in six months, same result.
- Terraform declares desired state; it computes the steps. Scripts declare steps.
- Idempotence means re-running a config is safe — unchanged means no changes.
- Your
.tffiles are executable documentation: versioned, diffable, reviewable.
Providers, Resources, and the State File
Terraform has three legs, and the whole thing falls over if you don't understand all three. Providers are translator plugins — one per platform — that turn Terraform's generic instructions into real API calls. Resources are the nouns you manage: a VM, a DNS record, a firewall rule. And the state file is Terraform's memory of what it built last time.
Here's the smallest possible illustration of the first two legs:
terraform {
required_version = ">= 1.9"
required_providers {
null = {
source = "hashicorp/null"
version = "~> 3.2"
}
}
}
provider "null" {}
resource "null_resource" "demo" {
triggers = { note = "nothing real -- just proving the loop works" }
}
The terraform block pins which providers this config needs (the null provider manages nothing real — it's the "hello world" of providers). The resource block declares one thing that should exist. That's the whole vocabulary: providers do, resources are.
The state file: source of truth and biggest footgun
After your first apply, Terraform writes a JSON file — terraform.tfstate — mapping each resource address to its real-world ID. It's how Terraform knows that null_resource.demo corresponds to whatever actually exists out there, and it's what makes the thermostat analogy work: desired state (your files) minus actual state (the state file, refreshed against reality) equals the plan.
{
"version": 4,
"resources": [
{
"mode": "managed",
"type": "null_resource",
"name": "demo",
"instances": [ { "attributes": { "id": "8675309" } } ]
}
]
}
This file is simultaneously the source of truth and the thing most likely to ruin your week. Delete it and Terraform develops amnesia: it will try to create everything again, duplicating infrastructure that already exists. Two people applying at once without state locking can corrupt it. And it stores secrets in plaintext — any password or key in your config lands in this file unencrypted.
terraform.tfstate, never commit it to git, and never lose it. If it's gone, your options are painful: manually import every resource (chapter 6) or accept duplicate infrastructure.One more subtlety worth knowing early: drift. If someone changes a resource by hand in the UI — bumps the RAM on a VM — the next plan will show Terraform wanting to change it back. That's not a bug; it's the thermostat doing its job. Whether you let it revert the change or update your files to match is a workflow decision, covered in chapter 6.
- Providers translate; resources declare; the state file remembers.
- The state file is a JSON map of resource address to real-world ID — lose it and Terraform forgets everything.
- State holds secrets in plaintext and needs locking the moment more than one actor applies.
init → plan → apply: Your First 20 Minutes
Install Terraform from the official install docs (one package-manager command on every platform), then confirm it with terraform version. The entire Terraform workflow is three commands, and the order matters:
terraform init downloads the providers your config requires and sets up the backend. You run it once per directory — and again any time you change the required_providers block. If init is complaining, the answer is almost always "run init again."
terraform plan is the dry run, and it's the whole reason this tool is trustworthy. It compares your files against the state file (refreshed against reality) and prints exactly what it would do, without doing any of it. terraform apply then executes — after asking you to type yes, which is your last line of defense.
Your first config
Create a directory, drop in this file as main.tf, and run the loop. The local provider writes files on your machine, so this works anywhere with zero cloud accounts:
terraform {
required_providers {
local = {
source = "hashicorp/local"
version = "~> 2.5"
}
}
}
resource "local_file" "hello" {
filename = "${path.module}/hello.txt"
content = "hello, homelab"
}
$ terraform init
$ terraform plan
Terraform will perform the following actions:
# local_file.hello will be created
+ resource "local_file" "hello" {
+ content = "hello, homelab"
+ filename = "./hello.txt"
+ id = (known after apply)
}
Plan: 1 to add, 0 to change, 0 to destroy.
How to read plan output
Learn these four symbols cold — they're the entire review surface of every infrastructure change you'll ever make:
+ create — this thing doesn't exist yet and will be made. ~ update in place — it exists and will be modified without being destroyed. - destroy — it exists and will be deleted. -/+ replace — destroy followed by create, because some attribute can't be changed on a live resource (like a VM's disk size on some platforms).
The summary line at the bottom — Plan: 1 to add, 0 to change, 0 to destroy. — is the part you check first, every time. If it says "0 to destroy" you can relax. If it says "3 to destroy" on a Tuesday morning, stop and read every line above it before typing yes.
plan is free, apply costs money and breaks things. The engineers who get burned by Terraform are, without exception, the ones who stopped reading plans.initsets up providers,planpreviews,applyexecutes after ayes.+create,~update,-destroy,-/+replace — read these before every apply.- Check the summary line first: any unexpected "to destroy" means stop and investigate.
Variables, tfvars, and Keeping Secrets Out of Git
Hardcode an IP address in main.tf and you've built a config that works exactly once, on exactly one machine, for exactly one purpose. The moment you want a second node — or your buddy wants to use your config — you're find-and-replacing through the file and hoping you caught them all. Variables fix this, and they fix it the way every good abstraction does: declare once, fill in per use.
# variables.tf -- committed to git
variable "node_name" {
type = string
description = "Hostname for the new VM"
}
variable "node_ip" {
type = string
description = "Static IP for the new VM, in CIDR notation"
}
variable "ssh_key" {
type = string
description = "Public SSH key installed for labuser"
sensitive = true
}
You reference these as var.node_name anywhere in your config. A variable with no default is required — Terraform will prompt for it if no value is supplied, which is fine exactly once and maddening forever. So you supply values in a terraform.tfvars file:
# terraform.tfvars -- NEVER committed to git
node_name = "k8s-worker-1"
node_ip = "192.0.2.21/24"
ssh_key = "ssh-ed25519 AAAA... labuser@homelab"
The gitignored-local-values pattern
This is the pattern that makes a homelab Terraform repo actually work: commit the shape, ignore the values. variables.tf and main.tf go in git — they're the reusable, shareable skeleton. terraform.tfvars holds everything machine-specific (IPs, hostnames, credentials) and lives only on your machine, listed in .gitignore:
# .gitignore
.terraform/
terraform.tfstate
terraform.tfstate.backup
terraform.tfvars
*.tfplan
Your buddy clones the repo, copies an example tfvars, fills in his own IPs, and the same config builds his lab. That's the whole trick, and it's the same pattern that keeps working when you graduate from homelab to team: per-environment values, one shared skeleton.
terraform.tfvars is not encryption — it's a plaintext file on your disk. sensitive = true only hides values from console output; they still sit in the state file unencrypted. Real secrets belong in environment variables (TF_VAR_ssh_key works automatically), a proper secrets manager, or at minimum a file that never leaves your machine. The one unforgivable sin is committing secrets to git, where they live in history forever.- Variables declare the shape;
terraform.tfvarssupplies the per-machine values. - Commit
variables.tf+main.tf; gitignoreterraform.tfvars, state files, and plan files. sensitive = truehides output, not storage — never commit secrets, period.
Real Project: Provisioning VMs Like Cattle
Time for the payoff. The scenario: your Kubernetes homelab needs another worker node. The old way is twenty minutes of clicking through the hypervisor UI plus an hour of debugging the cloud-init snippet you half-remembered. The Terraform way is one command — but it rests on a pattern you set up once.
The golden-image pattern
Build one template VM by hand: base OS, updates, your SSH key, the baseline packages every node needs, cloud-init configured. Convert it to a template and never touch it again except to rebuild it deliberately. Every new node is a clone of that template — identical by construction, not by your memory of the checkboxes. The template is the contract; Terraform is the photocopier.
The config below is deliberately generic — resource and argument names vary between hypervisor providers, so treat this as the shape of the pattern and check your provider's docs for exact spelling:
variable "node_count" {
type = number
description = "How many worker nodes to clone"
default = 1
}
resource "vm_clone" "k8s_worker" {
count = var.node_count
name = "k8s-worker-${count.index + 1}"
target = var.hypervisor_node
template = var.golden_image # baked once, cloned forever
cores = 4
memory = 8192
network {
bridge = "vmbr0"
ip = "192.0.2.2${count.index + 1}/24"
gw = "192.0.2.1"
}
cloud_init = {
user = "labuser"
ssh_keys = [var.ssh_key]
}
}
Run terraform apply and you get k8s-worker-1 at 192.0.2.21, configured identically to every node that came before it. Need three workers for the cluster? Change node_count to 3 in your tfvars, run plan — it shows two clean + creates, zero surprises — and apply.
apply, and the lab stands back up. Try doing that from a wiki page of screenshots.Notice what changed about your relationship with the infrastructure: "new k8s node" went from a procedure to a parameter. That's the desired-state mindset from chapter 1, now with a concrete win attached. And because the whole thing is in git (minus the tfvars), you can show your work — which, for a presales SE, is half the job.
- Bake one golden-image template (OS + cloud-init + SSH key); clone it for every node.
- Terraform turns "new node" from a 20-minute procedure into a one-command parameter change.
- Provider syntax varies — learn the pattern here, look up the exact resource names in your provider's docs.
Workflows That Don't Burn You
Knowing the commands is the easy part. The engineers who thrive with Terraform are the ones with boring, disciplined workflows — because Terraform's failure modes are all "it did exactly what you told it to," and what you told it to is forever in the plan output you may or may not have read.
Directories vs workspaces
You need separate lab and "production" (your always-on services) environments. Two options: separate directories with duplicated config, or workspaces — lightweight state separations sharing one config (terraform workspace new prod). Rule of thumb for a homelab: use directories until the duplication genuinely hurts, then consider workspaces. Explicit beats clever when you're the entire operations team.
Adopting strays with import
That VM you built by hand six months ago? You don't have to rebuild it — you adopt it. Write the resource block describing it, add an import block pointing at its real ID, and run plan to reconcile any differences between your block and reality:
import {
to = vm_clone.adopted
id = "hv1/104"
}
resource "vm_clone" "adopted" {
name = "old-reliable"
# ... fill in the real settings, then plan to verify the match
}
Replace, don't nurse
A node is sick in a way you don't fully understand? Don't spend the evening performing surgery. terraform apply -replace="vm_clone.k8s_worker[0]" destroys that one instance and creates a fresh clone from the golden image. Cattle, not pets — chapter 5's pattern pays off again.
terraform destroy deletes everything in the state file, and it only asks once. Before you ever type it, confirm you're in the right directory and the right workspace. "I destroyed prod instead of lab" is a rite of passage nobody wants.And the workflow that prevents most disasters before they start: plan in CI before you apply anywhere. Every pull request runs terraform plan -out=tfplan; a human reviews the diff; only merges get terraform apply tfplan — which applies exactly the reviewed plan, not whatever the files look like now. Even solo, this habit is worth it: the plan file is a receipt for what you approved.
The dangerous checklist
- Write a config using the
localprovider, runinit→plan→apply, and read every symbol in the plan output before typingyes. - Move a hardcoded value into a variable with a gitignored
terraform.tfvars, and verify withgit statusthat no secrets or state files would be committed. - Build a golden-image template on your hypervisor and clone your first VM from it with a single
apply. - Adopt one hand-built resource with an
importblock and reconcile it untilplanshows no changes. - Set up plan-on-every-change so nothing applies without a reviewed plan — even if "CI" is just you reading the output twice.