Sscribnet.io
Zero to Dangerous

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.

6 chapters~25 min readBeginner
Chapter 1

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.

Chapter 2

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.

Footgun: never hand-edit 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.
Tip: state locking is what makes teams safe. The default local backend has no locking — fine for a solo homelab, genuinely dangerous the moment a second human (or a CI job) touches the same config. Remote backends lock the state during applies.

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.

Chapter 3

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.

Tip: make plan-reading a reflex: 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.
Chapter 4

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.

Footgun: 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.
Chapter 5

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.

Tip: this is also your disaster-recovery story. Hypervisor dies? Your nodes aren't snowflakes you mourn — they're entries in a file. Reinstall the hypervisor, restore the template, 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.

Chapter 6

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.

Footgun: 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