PlatformEngr® Terraform course

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

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 kubernetes provider turns resource blocks 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_version in versions.tf says which CLI versions the code accepts. >= 1.11.0, < 2.0.0 allows every 1.x from 1.11.
  • ~> 1.5.0 lets 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 versionThe CLI version, and the providers this directory uses
terraform initPrepare a directory: providers, modules, lock file, backend
kubectl config current-contextWhich 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

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 replacement marks 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.hcl is committed; .terraform/ and *.tfstate never are.

Pitfalls

  • Typing a namespace name by hand: namespaces "checkout-develop" not found at apply time, after the plan looked fine. Use a reference.
  • Applying a plan that says must be replaced on 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 fmtFormat the files
terraform validateValid HCL, known arguments, right types
terraform planThe steps an apply would take. Changes nothing
terraform applyPlan again, ask, change, record
terraform state listEvery address Terraform manages here
git ls-filesWhat 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

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, and validation { 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), -var and -var-file.
  • ***.auto.tfvars** is loaded automatically and is often ignored by Git.

Pitfalls

  • A leftover zz-local.auto.tfvars sets environment = "prod": the plan replaces everything and git status is clean. Ask terraform console, not Git.
  • Copying a literal into three places instead of a local: one of them is forgotten in the next change.

Commands

terraform consoleEvaluate an expression: var.environment, local.namespace
terraform plan -var environment=stagingPlan with one input changed, this run only
terraform output -raw checkout_namespaceOne output, without quotes
ls -aFiles 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

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; key dev/terraform.tfstate.
  • use_lockfile = true: the lock is an object next to the state, ending in .tflock. No DynamoDB.
  • sensitive hides 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. Raise data_wo_revision to rotate.
  • init -migrate-state copies state to the new backend; init -reconfigure starts 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. Read Lock Info before anything else.
  • Committing terraform.tfstate: every secret it ever held stays in Git history.

Commands

terraform state show ADDRESSEverything state records for one object
terraform state pullThe whole state as JSON
terraform init -migrate-stateMove state to the backend in backend.tf
docker compose -f state/compose.yaml up -dStart the state store
set -a && . state/.env && set +aLoad 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

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 become module.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 with Instance 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>, a deployer ServiceAccount 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 initAgain after adding a module call
terraform state listAddresses now start with module.<name>.
kubectl auth can-i list pods -n NS --as=jane --as-group=team-checkoutWould 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

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.tfvars and main.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.tfstate in the same bucket, same credentials, same commit.
  • CODEOWNERS names who reviews modules/, envs/prod/, state/ and .github/.

Pitfalls

  • Copying dev's backend.tf into 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 listNamed states for this directory
terraform init -reconfigureUse the backend in backend.tf, copy nothing
kubectl get namespaces -l environment=prodWhat 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

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 test runs *.tftest.hcl; each run plans the module and checks assert conditions.
  • mock_provider "kubernetes" {}: no cluster, no credentials; computed values are invented.
  • expect_failures = [var.environment]: passes only if that validation refuses the input.
  • check block: 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 testRun the module's tests
terraform fmt -check -recursiveFail on unformatted files
terraform init -backend=false -lockfile=readonlyInit 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 modulesThe policy, on the files
gh pr checks --watchWait for the pull request's checks
gh run view --log-failedWhy 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

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 says 1 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, init refuses, init -upgrade, read the lock diff and the changelog, then a plan where every line is explained and 0 to destroy; after the apply, No changes.
  • providers lock -platform=... adds checksums for other machines; CI's -lockfile=readonly needs Linux ones.
  • name_override: an optional input for a namespace that predates the naming rule.

Pitfalls

  • Deleting an object so Terraform can create it: import it instead.
  • Deleting .terraform.lock.hcl to 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-onlyOnly what changed outside Terraform
terraform init -upgradeNewest allowed versions; rewrites the lock file
terraform providers lock -platform=linux_amd64 -platform=linux_arm64 -platform=darwin_amd64 -platform=darwin_arm64Checksums for every platform
git diff .terraform.lock.hclWhat 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

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 Info gives Who, Created, Operation. force-unlock <ID> only when that operation is dead.
  • Type rename (kubernetes_config_map to _v1): a new address. moved where the provider supports it, removed with destroy = false plus import where 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 a local.
  • 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 -target to 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_IDRemove a dead lock
pgrep -fl terraformIs the process in Who still running?
terraform plan -refresh-onlySeparate 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

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.viewers with static keys (team, audit); the team's own binding keeps the name team-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; then kubectl delete namespace as a deliberate step.

Pitfalls

  • Adding for_each without moved: every team's binding is destroyed and recreated, prod too.
  • Deleting a team's module block to retire it: prevent_destroy leaves with the block, and the plan deletes the namespace with no error. Use removed with destroy = false.
  • Removing prevent_destroy from 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 testFive runs, all pass
kubectl auth can-i list pods -n payments-prod --as=x --as-group=team-finance-audityes; for checkout-prod, no
terraform state listNo 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.