Cheat notes · Terraform
Terraform: cheat notes
Every module on one page, cut to what you need to remember: the idea, the commands, the pitfalls and a few questions to test yourself. Made for review after you have done the course, not instead of it. Back to the course.
00 Orientation: what a platform team keeps in Terraform
This module only · Full module
The idea
Terraform compares three things: the code in your .tf files, the state (its record of what it manages) and the real objects, and proposes the steps to make reality match the code. A platform team keeps the shared, long-lived things in it: here, each team's namespace and access.
Remember
- Provider: a plugin that knows one API. The
kubernetesprovider turnsresourceblocks into calls to the API server; Terraform itself knows no API. - Root module: one directory you run Terraform in, with one state. The first one is
envs/dev. required_versioninversions.tfsays which CLI versions the code accepts.>= 1.11.0, < 2.0.0allows every 1.x from 1.11.~> 1.5.0lets only the last number grow: 1.5.x, below 1.6.0.- Terraform and OpenTofu read the same language. Pick one command per repo and keep it.
- The kube context is
kind-tf; one kind node is one Docker container.
Pitfalls
Unsupported Terraform Core version: the constraint and your CLI disagree. Widen the constraint to what the code needs; do not install an old CLI.- Running Terraform against whatever kube context is current. The provider names its context explicitly from module 01 on.
Commands
terraform version | The CLI version, and the providers this directory uses |
terraform init | Prepare a directory: providers, modules, lock file, backend |
kubectl config current-context | Which cluster you are about to change |
envs/dev/versions.tf (module 00)
terraform {
required_version = ">= 1.11.0, < 2.0.0"
}
Test yourself
Answer in your head first, then open the answer.
Which three things does a plan compare?
The code, the state and the real objects.
What does ~> 1.5.0 accept?
1.5.0 and newer patch releases, below 1.6.0.
Why does the state matter?
It is the only way Terraform knows which real objects are its own, and which block each belongs to.
Where module 00 leaves you: A cluster kind-tf with one Ready node. Your repo on GitHub holds cluster/kind.yaml, .gitignore and envs/dev/versions.tf. terraform init succeeds in envs/dev. Nothing is managed yet.
01 Write, plan, apply: a namespace from code
This module only · Full module
The idea
You write what should exist, plan shows the steps without changing anything, and apply runs them and records the result in state. The address (type plus name) is how Terraform knows an object; the last line of the plan is what you read first.
Remember
- Address:
kubernetes_namespace_v1.checkout. The type comes from the provider, the name is yours. - Plan symbols:
+create,~update in place,-/+destroy and create again (must be replaced). # forces replacementmarks the field that makes an update impossible, such as a namespace's name.- A reference (
kubernetes_namespace_v1.checkout.metadata[0].name) passes a value and sets the order. - Apply is not a transaction: if the second action fails, the first stays done and recorded.
.terraform.lock.hclis committed;.terraform/and*.tfstatenever are.
Pitfalls
- Typing a namespace name by hand:
namespaces "checkout-develop" not foundat apply time, after the plan looked fine. Use a reference. - Applying a plan that says
must be replacedon a namespace: everything inside it is deleted. - Reading the plan from the top. Read the last line first:
Plan: X to add, Y to change, Z to destroy.
Commands
terraform fmt | Format the files |
terraform validate | Valid HCL, known arguments, right types |
terraform plan | The steps an apply would take. Changes nothing |
terraform apply | Plan again, ask, change, record |
terraform state list | Every address Terraform manages here |
git ls-files | What is really in Git |
envs/dev/main.tf (module 01)
resource "kubernetes_namespace_v1" "checkout" {
metadata {
name = "checkout-dev"
labels = {
team = "checkout"
}
}
}
resource "kubernetes_resource_quota_v1" "checkout" {
metadata {
name = "team-quota"
namespace = kubernetes_namespace_v1.checkout.metadata[0].name
}
spec {
hard = {
pods = "10"
"requests.cpu" = "2"
"requests.memory" = "2Gi"
}
}
}
Test yourself
Answer in your head first, then open the answer.
What is the difference between plan and apply?
plan only proposes; apply makes the changes and writes them to state.
Why use a reference instead of the namespace's name as a string?
It cannot be mistyped, and it tells Terraform to create the namespace first.
An apply fails halfway. What is in state?
Whatever succeeded. Run plan to see what is left.
Which file do you commit so everyone gets the same provider?
.terraform.lock.hcl.
Where module 01 leaves you: Namespace checkout-dev (label team=checkout) with ResourceQuota team-quota and LimitRange team-limits, all created by Terraform, and a ConfigMap team-contact. envs/dev has versions.tf, providers.tf, main.tf and a committed .terraform.lock.hcl. The state is a file on your laptop that is not in Git.
02 Inputs, locals, outputs and validation
This module only · Full module
The idea
Variables are typed inputs with rules, locals name an expression once, and outputs publish values. Validation refuses bad input before any plan; it cannot tell a valid value from the right one.
Remember
variable: type, optional default, description, andvalidation { condition, error_message }.optional(number, 10)in an object type gives a field its default.- Local:
local.namespace, computed once, not an input. - Precedence, later wins: default,
TF_VAR_name,terraform.tfvars,*.auto.tfvars(alphabetical),-varand-var-file. - **
*.auto.tfvars** is loaded automatically and is often ignored by Git.
Pitfalls
- A leftover
zz-local.auto.tfvarssetsenvironment = "prod": the plan replaces everything andgit statusis clean. Askterraform console, not Git. - Copying a literal into three places instead of a local: one of them is forgotten in the next change.
Commands
terraform console | Evaluate an expression: var.environment, local.namespace |
terraform plan -var environment=staging | Plan with one input changed, this run only |
terraform output -raw checkout_namespace | One output, without quotes |
ls -a | Files Git may be hiding |
envs/dev/variables.tf (module 02)
variable "environment" {
description = "Which environment this directory manages."
type = string
validation {
condition = contains(["dev", "prod"], var.environment)
error_message = "environment must be dev or prod."
}
}
Test yourself
Answer in your head first, then open the answer.
When does a validation block run?
Before the plan, when the value is set.
terraform.tfvars says dev but the plan uses prod. Where do you look?
*.auto.tfvars files and -var flags; terraform console shows the value in use.
Variable, local or output: the namespace name built from team and environment?
A local.
Where module 02 leaves you: The same objects in checkout-dev, now with labels team, environment, tier and app.kubernetes.io/managed-by=terraform. envs/dev has variables.tf, terraform.tfvars and outputs.tf. terraform plan says No changes.
03 State: a shared backend, a lock, and the secrets inside
This module only · Full module
The idea
State maps every address to a real object and stores all its attributes, secrets included, in plain text. A shared, locked backend lets a team use one state safely; keeping secrets out of it is a separate decision per secret.
Remember
- Backend: where state lives. Here
backend "s3"against a local S3-compatible store; keydev/terraform.tfstate. use_lockfile = true: the lock is an object next to the state, ending in.tflock. No DynamoDB.sensitivehides a value in output; it is still in state.- Ephemeral resource + write-only argument (
ephemeral "random_password",data_wo): the value reaches the API and is never stored. Raisedata_wo_revisionto rotate. init -migrate-statecopies state to the new backend;init -reconfigurestarts fresh and copies nothing.- Read access to the bucket is read access to every secret in state.
Pitfalls
- Changing the key and running
init -reconfigure: the plan wants to create everything that exists. Terraform is reading an empty state; put the key back. - A plan that waits on
Error acquiring the state lock: someone else is writing. ReadLock Infobefore anything else. - Committing
terraform.tfstate: every secret it ever held stays in Git history.
Commands
terraform state show ADDRESS | Everything state records for one object |
terraform state pull | The whole state as JSON |
terraform init -migrate-state | Move state to the backend in backend.tf |
docker compose -f state/compose.yaml up -d | Start the state store |
set -a && . state/.env && set +a | Load the store's credentials into this terminal |
envs/dev/backend.tf
terraform {
backend "s3" {
bucket = "tfstate"
key = "dev/terraform.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
Plus six lines that only exist because the store is not AWS.
Test yourself
Answer in your head first, then open the answer.
Why not keep state in Git?
No lock, so two applies overwrite each other, and every secret stays in history.
What does sensitive = true protect against?
Showing the value in plans and outputs. Not storing it in state.
-migrate-state or -reconfigure: which copies the state?
-migrate-state.
How does data_wo keep a password out of state?
It is write-only: sent to the API on apply and never recorded; the value comes from an ephemeral resource.
Where module 03 leaves you: The state of envs/dev is the object dev/terraform.tfstate in the bucket tfstate, locked while anyone plans or applies. No state file is on disk or in Git. Secret checkout-db exists in checkout-dev and its value is not in the state. envs/dev/backend.tf and state/compose.yaml are committed; state/.env is not.
04 One module, two teams
This module only · Full module
The idea
A module packages resources behind inputs and outputs, so every team gets the same kit from a few lines. Moving a block changes its address; a moved block says it is the same object, and prevent_destroy turns a plan that would delete a namespace into an error.
Remember
- Module call:
module "checkout" { source = "../../modules/team-namespace" ... }; addresses becomemodule.checkout.kubernetes_namespace_v1.this. - A module declares a provider version range (
>= 3.0.0, < 4.0.0); the caller configures the provider and pins the exact version. moved { from = ... to = ... }: old address, new address, nothing recreated.lifecycle { prevent_destroy = true }: any plan that would destroy it fails withInstance cannot be destroyed, as long as the block is still in the code.- A default is every caller's value. A difference between teams is an input in the caller.
- The kit: namespace, quota, limit range, a read-only binding for
team-<name>, adeployerServiceAccount with a Role that cannot read Secrets.
Pitfalls
- Moving resources into a module without
moved: the plan destroys and recreates them. - Changing a default in the module for one team: every caller that relies on it changes too. Read the plan of every caller.
Commands
terraform init | Again after adding a module call |
terraform state list | Addresses now start with module.<name>. |
kubectl auth can-i list pods -n NS --as=jane --as-group=team-checkout | Would that group be allowed? |
envs/dev/moved.tf
moved {
from = kubernetes_namespace_v1.checkout
to = module.checkout.kubernetes_namespace_v1.this
}
Test yourself
Answer in your head first, then open the answer.
Why does moving a block into a module plan a destroy?
Terraform matches by address, and the address changed.
Where is the provider configured for a module's resources?
In the root module that calls it.
What does Instance cannot be destroyed tell you?
The plan would destroy a resource with prevent_destroy; find out why first.
Where module 04 leaves you: modules/team-namespace creates seven objects per team. envs/dev/main.tf calls it twice: module "checkout" and module "catalog". checkout-dev was never deleted. Both namespaces have team-quota, team-limits, a read-only binding for the team's group and a deployer ServiceAccount. catalog may run 20 pods.
05 Dev and prod without copy and paste
This module only · Full module
The idea
Dev and prod are two directories with the same module and different wiring: their own state key, target and inputs, reviewed as files. A workspace is only a second state for the same files, invisible in a pull request.
Remember
- Environment directory:
backend.tf(which state),providers.tf(which target),terraform.tfvarsandmain.tf(which numbers). No logic. - Blast radius: one state. Dev's lock does not block prod, and a mistake in dev cannot touch prod.
- Promotion: one merge, then an apply in dev, then in prod.
- Workspace:
env:/prod/dev/terraform.tfstatein the same bucket, same credentials, same commit. - CODEOWNERS names who reviews
modules/,envs/prod/,state/and.github/.
Pitfalls
- Copying dev's
backend.tfinto prod: prod's code runs against dev's state and plans to destroy dev.grep -n key envs/*/backend.tf. - A workspace per environment: nobody can see in the pull request which one a change will hit.
Commands
terraform workspace list | Named states for this directory |
terraform init -reconfigure | Use the backend in backend.tf, copy nothing |
kubectl get namespaces -l environment=prod | What prod manages |
envs/prod/main.tf (module 05)
module "checkout" {
source = "../../modules/team-namespace"
team = "checkout"
environment = var.environment
quota = { pods = 30, cpu = "8", memory = "16Gi" }
}
Test yourself
Answer in your head first, then open the answer.
What does terraform workspace new prod create?
A second, empty state for the same code in the same backend.
You apply a module change in dev only. What does prod's plan show?
The same change, waiting for prod's apply.
Which line differs between dev's and prod's backend.tf?
The key.
Where module 05 leaves you: envs/prod manages checkout-prod and catalog-prod (seven objects each) from the same module, with its state in prod/terraform.tfstate and its own lock. .github/CODEOWNERS names who reviews modules/, envs/prod/, state/ and .github/. Only the default workspace exists.
06 Tests, checks and a pipeline on every pull request
This module only · Full module
The idea
Some things can be proven from the files alone: format, validity, tests against a fake provider, and policy. A plan needs the real state and API, so it is not in a pull request workflow that cannot reach them. check blocks warn; validations and tests block.
Remember
terraform testruns*.tftest.hcl; eachrunplans the module and checksassertconditions.mock_provider "kubernetes" {}: no cluster, no credentials; computed values are invented.expect_failures = [var.environment]: passes only if that validation refuses the input.checkblock:Warning: Check block assertion failed, and the plan carries on.- CI jobs:
static(fmt,init -backend=false -lockfile=readonly, validate) per environment,test,policy(conftest on the HCL). - A module's own lock file is ignored by Git: a reusable module does not pin exact versions.
Pitfalls
- All checks green while a rule is broken: a rule that has no test exists only in people's heads.
- A new test that has never failed may assert nothing. Make it fail once for the reason it was written.
- Expecting CI to catch a destroy: without a plan job, only the person who pastes the plan sees it.
Commands
terraform test | Run the module's tests |
terraform fmt -check -recursive | Fail on unformatted files |
terraform init -backend=false -lockfile=readonly | Init without state; fail if the lock file is wrong |
docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:v0.71.1 test --parser hcl2 --ignore '\.terraform' -p policy envs modules | The policy, on the files |
gh pr checks --watch | Wait for the pull request's checks |
gh run view --log-failed | Why a check failed |
modules/team-namespace/tests/unit.tftest.hcl
mock_provider "kubernetes" {}
variables {
team = "checkout"
environment = "dev"
}
run "deployer_cannot_read_secrets" {
command = plan
assert {
condition = !contains(flatten([for r in kubernetes_role_v1.deployer.rule : r.resources]), "secrets")
error_message = "The deployer role must not include secrets."
}
}
Test yourself
Answer in your head first, then open the answer.
What does a mocked test prove, and what not?
Your own expressions and defaults; nothing about what the API will answer.
A check assertion fails during apply. What happens?
A warning; the apply completes.
Why is there no plan job in this workflow?
The runner cannot reach the cluster or the state store.
Where module 06 leaves you: modules/team-namespace/tests/unit.tftest.hcl has four run blocks and terraform test passes. envs/dev and envs/prod each have a check block. .github/workflows/terraform.yaml runs three jobs on pull requests; none of them applies anything. One pull request has gone through it.
07 Import, drift and provider upgrades
This module only · Full module
The idea
Code, state and world drift apart in three ways: something exists that state does not know (import it), something was changed by hand (see it with a refresh-only plan, then decide), and the provider moves on (constraint, init -upgrade, empty plan). Objects Terraform never managed are in no plan.
Remember
import { to = ADDRESS id = "search-dev" }: adopt on the next apply; the plan says1 to import.- Import only writes state. The code must already describe the object; the plan then changes it to match.
plan -refresh-only: shows what changed outside Terraform; proposes updating state, never the objects.- Drift has two honest outcomes: put the hand change in code, or apply to undo it.
- Upgrade: change the version,
initrefuses,init -upgrade, read the lock diff and the changelog, then a plan where every line is explained and0 to destroy; after the apply,No changes. providers lock -platform=...adds checksums for other machines; CI's-lockfile=readonlyneeds Linux ones.name_override: an optional input for a namespace that predates the naming rule.
Pitfalls
- Deleting an object so Terraform can create it:
importit instead. - Deleting
.terraform.lock.hclto get past a version error: the upgrade becomes invisible in review. - An import whose plan destroys: the module computes a different name. The bar for an import is
0 to destroy. - Trusting
No changes.: a RoleBinding made by hand is outside every plan.
Commands
terraform plan -refresh-only | Only what changed outside Terraform |
terraform init -upgrade | Newest allowed versions; rewrites the lock file |
terraform providers lock -platform=linux_amd64 -platform=linux_arm64 -platform=darwin_amd64 -platform=darwin_arm64 | Checksums for every platform |
git diff .terraform.lock.hcl | What the upgrade changed: version, constraints, hashes |
kubectl -n NS patch resourcequota team-quota --type merge -p '{...}' | A hand change, for practice |
envs/dev/imports.tf
import {
to = module.search.kubernetes_namespace_v1.this
id = "search-dev"
}
Test yourself
Answer in your head first, then open the answer.
What does an import block do on apply?
It records an existing object at the address; nothing is created.
How do you see only the hand changes?
terraform plan -refresh-only.
What is the proof that a provider upgrade is safe here?
An empty plan after init -upgrade, with the changelog read.
Where module 07 leaves you: search-dev is managed by module "search" and was never recreated. envs/dev/imports.tf holds the import block. Both environments are on provider 3.5.0 with lock files that carry checksums for Linux and macOS. .github/dependabot.yml proposes future upgrades. Every plan says No changes.
08 Debugging labs: five plans that go wrong
This module only · Full module
The idea
Every Terraform failure is a disagreement between two of code, state and world. Name the two, read the one message that names the cause, and never fix a state problem by changing infrastructure.
Remember
- Stale lock:
Lock Infogives Who, Created, Operation.force-unlock <ID>only when that operation is dead. - Type rename (
kubernetes_config_mapto_v1): a new address.movedwhere the provider supports it,removedwithdestroy = falseplusimportwhere it does not. - Hand fix in prod: if the value in the cluster is right, put it in the code.
Error: Cycle: two resources refer to each other. Share a value both know through alocal.Invalid for_each argument: keys must be known at plan time. A map with keys you write, values that may be computed.- Never
-lock=false, never-targetto get past a design problem.
Pitfalls
- Forcing a live lock: two writers, one corrupted state.
- Following a deprecation warning by renaming the type:
2 to add, 2 to destroy. for_each = toset([random_pet.x.id]): the keys depend on a value that exists only after apply.
Commands
terraform force-unlock LOCK_ID | Remove a dead lock |
pgrep -fl terraform | Is the process in Who still running? |
terraform plan -refresh-only | Separate the hand change from yours |
labs/upgrade/refactor.tf
moved {
from = kubernetes_namespace.tools
to = kubernetes_namespace_v1.tools
}
removed {
from = kubernetes_config_map.banner
lifecycle {
destroy = false
}
}
import {
to = kubernetes_config_map_v1.banner
id = "platform-tools/banner"
}
Test yourself
Answer in your head first, then open the answer.
Lock Info shows an apply from 30 seconds ago. What do you do?
Wait or ask the person in Who; the lock is doing its job.
Why does renaming a resource type plan a destroy?
The type is part of the address.
How do you break a cycle between a namespace and a ServiceAccount?
Put the value both need, the account's name, in a local.
Which part of for_each must be known at plan time?
The keys.
Where module 08 leaves you: Every plan in envs/dev and envs/prod says No changes. labs/upgrade is on provider 3.5.0 with nothing recreated. Prod's checkout quota is 40 pods, in code. Every team namespace carries a platformengr.com/deployer annotation. Your repo has docs/runbook.md.
09 Capstone: onboard team-payments on your own
This module only · Full module
The idea
Onboarding a team that needs something new means extending the module with an optional input whose default keeps today's behaviour, proving it with a test and an empty plan for every existing team, then adding the calls dev first. Retiring a team is forgetting in Terraform, then deleting by hand.
Remember
extra_viewers = map(string), default{}: existing callers change nothing.for_each = local.viewerswith static keys (team,audit); the team's own binding keeps the nameteam-view.moved { from = kubernetes_role_binding_v1.view to = kubernetes_role_binding_v1.view["team"] }in the module, so every caller gets it.- Order: module and test, empty plans, the calls, the import, then prod, in one pull request.
removed { from = module.reporting lifecycle { destroy = false } }: out of state, still running; thenkubectl delete namespaceas a deliberate step.
Pitfalls
- Adding
for_eachwithoutmoved: every team's binding is destroyed and recreated, prod too. - Deleting a team's module block to retire it:
prevent_destroyleaves with the block, and the plan deletes the namespace with no error. Useremovedwithdestroy = false. - Removing
prevent_destroyfrom the module to retire one team: every team loses the guard. - Making the new input required: every caller must change, and the change is no longer additive.
Commands
terraform test | Five runs, all pass |
kubectl auth can-i list pods -n payments-prod --as=x --as-group=team-finance-audit | yes; for checkout-prod, no |
terraform state list | No module.reporting left |
modules/team-namespace/rbac.tf (the view binding)
resource "kubernetes_role_binding_v1" "view" {
for_each = local.viewers
metadata {
name = each.key == "team" ? "team-view" : "view-${each.key}"
namespace = kubernetes_namespace_v1.this.metadata[0].name
}
role_ref {
api_group = "rbac.authorization.k8s.io"
kind = "ClusterRole"
name = "view"
}
subject {
api_group = "rbac.authorization.k8s.io"
kind = "Group"
name = each.value
}
}
Test yourself
Answer in your head first, then open the answer.
What is the bar for a module change?
An empty plan (or only has moved to) for every existing caller.
Why are the for_each keys team and audit, not the group names?
Keys are part of the address; names you write stay stable and known at plan time.
What does removed with destroy = false do on apply?
Takes the objects out of state and leaves them running.
Where module 09 leaves you: payments-dev and payments-prod have the full kit plus a second read-only binding for team-finance-audit. No other team's objects were recreated. The module has an extra_viewers input, a moved block and five tests. docs/onboarding.md says how to do it again. team-reporting is out of Terraform's state and its namespace is gone.