Back to All Cheatsheet Libraries cheatsheets

Terraform

CLI command reference, core concepts, and a safe-change workflow for Terraform.

State is the whole story

Terraform keeps a JSON file mapping the resources in your config to the real objects in your provider. Every plan is a three-way comparison: your code, the state file, and what actually exists.

Almost every confusing Terraform problem is a state problem — state that disagrees with reality, two people writing state at once, or a resource that exists but isn't tracked. Understand state and the rest follows.

Block What it does Notes
resourceCreates and manages a real object.The only block that actually makes things exist.
dataReads something that already exists.Read-only. Re-evaluated every plan, so it can cause unexpected diffs.
variableAn input to the module.Always set type and description. Add validation for constrained values.
outputA value exposed to the caller or the CLI.Mark credentials sensitive = true — though see the state warning below.
localsNamed expressions within a module.For readability and to avoid repeating a computed value.
moduleCalls a reusable group of resources.Always pin the version for remote modules.
providerConfigures a target platform — AWS, Azure, GCP, Kubernetes.Use alias for multiple regions or accounts.
terraformSettings: backend, required versions, provider constraints.Pin both required_version and every provider version.
movedRecords that a resource was renamed.Refactor without destroy/recreate — far safer than state mv.
importBrings an existing object under management, declaratively.Terraform 1.5+. Reviewable in a plan, unlike terraform import.

A layout that scales

The most common structural mistake is one enormous state file for everything. Split by blast radius: things that change hourly should not share state with things that must never be destroyed.

infrastructure/ ├── modules/ # reusable, no backend, no hardcoded values │ ├── network/ │ │ ├── main.tf │ │ ├── variables.tf │ │ ├── outputs.tf │ │ └── README.md │ └── app-service/ └── environments/ # one state file each ├── prod/ │ ├── main.tf # calls modules │ ├── backend.tf # remote state + locking │ ├── terraform.tfvars │ └── .terraform.lock.hcl # COMMIT THIS ├── staging/ └── dev/
Group Command Does
Coreterraform initDownload providers and modules, configure the backend. Run after any change to either.
Coreterraform init -upgradeRe-resolve versions within your constraints and update the lock file.
Coreterraform init -reconfigureChange backends without attempting to migrate existing state.
Coreterraform plan -out=tf.planAlways save the plan. Applying a saved plan guarantees you apply exactly what you reviewed.
Coreterraform apply tf.planApply a saved plan. No second prompt — it's already approved.
Coreterraform destroyTear everything down. -target to limit scope.
Coreterraform fmt -recursiveCanonical formatting. Add -check in CI.
Coreterraform validateSyntax and internal consistency. No provider calls, so it's fast and offline.
Planterraform plan -target=aws_instance.webLimit to one resource. An escape hatch, not a workflow — it skips dependency checks.
Planterraform plan -refresh=falseSkip re-reading real infrastructure. Much faster on large states.
Planterraform show -json tf.planMachine-readable plan — the basis for policy checks in CI.
Stateterraform state listEvery tracked resource address.
Stateterraform state show ADDRFull attributes of one resource as recorded in state.
Stateterraform state mv SRC DSTRename in state. Prefer a moved block — it's reviewable and version-controlled.
Stateterraform state rm ADDRStop managing without destroying. The object keeps existing, untracked.
Stateterraform state pull > backup.tfstateDo this before any state surgery.
Stateterraform force-unlock LOCK_IDClear a stuck lock. Verify nothing is actually running first.
Importterraform import ADDR REAL_IDLegacy imperative import. Writes state immediately, unreviewable.
Importterraform plan -generate-config-out=gen.tfWith an import block, generates the HCL for you. Terraform 1.5+.
Debugterraform consoleInteractive REPL for testing expressions against real state. Badly underused.
Debugterraform graph | dot -Tsvg > g.svgVisualise the dependency graph.
DebugTF_LOG=DEBUG terraform applyVerbose logs including provider API calls. TF_LOG_PATH to write to a file.
Workspaceterraform workspace list / select NAMEMultiple states from one config. Not a good fit for prod vs dev — see Gotchas.

The constructs you'll use daily

# Pin everything. Unpinned versions are how a plan changes overnight. terraform { required_version = "~> 1.9" required_providers { aws = { source = "hashicorp/aws", version = "~> 5.60" } } } # Typed, validated variable variable "environment" { type = string description = "Deployment environment" validation { condition = contains(["dev", "staging", "prod"], var.environment) error_message = "environment must be dev, staging, or prod." } } # for_each over a map — keys are stable, so removing one entry does NOT # reindex the others. Prefer this to count in almost every case. resource "aws_s3_bucket" "assets" { for_each = toset(["images", "video", "docs"]) bucket = "${var.environment}-${each.key}" } # count is fine for a simple on/off toggle resource "aws_instance" "bastion" { count = var.enable_bastion ? 1 : 0 ami = data.aws_ami.ubuntu.id instance_type = "t3.micro" } # Locals for computed values used in several places locals { common_tags = { Environment = var.environment ManagedBy = "terraform" } } # Dynamic nested blocks resource "aws_security_group" "web" { dynamic "ingress" { for_each = var.allowed_ports content { from_port = ingress.value to_port = ingress.value protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] } } } # Lifecycle controls resource "aws_db_instance" "main" { # ... lifecycle { prevent_destroy = true # refuses to be destroyed create_before_destroy = true # avoids downtime on replace ignore_changes = [tags["LastScanned"]] } } # Renaming without destroy/recreate moved { from = aws_instance.web to = aws_instance.frontend } # Declarative import (1.5+) — shows up in the plan for review import { to = aws_s3_bucket.legacy id = "my-existing-bucket" }
for_each vs count
  • count indexes by position: aws_instance.web[0]. Removing the first element renumbers every later one, so Terraform destroys and recreates them all.
  • for_each indexes by key: aws_instance.web["api"]. Removing one entry touches only that one.
  • Use for_each for anything list-like. Reserve count for a boolean 0-or-1 toggle.
  • Neither may depend on a value that isn't known until apply — that's the "Invalid for_each argument" error.
Functions worth knowing
  • try(a, b, "default") — first expression that doesn't error. Cleaner than nested conditionals.
  • coalesce() / coalescelist() — first non-null / non-empty.
  • merge(map1, map2) — combine maps; later keys win. The standard tag pattern.
  • lookup(map, key, default) — safe map access.
  • templatefile(path, vars) — render a file with variables. Prefer over inline heredocs.
  • jsonencode() — build IAM policies in HCL rather than as fragile strings.
  • cidrsubnet(prefix, newbits, num) — carve subnets without hand-calculating.
  • Test any of these in terraform console before committing them.

Remote state with locking is not optional for a team

Local state means one laptop holds the only record of your infrastructure, and two people applying at once will corrupt it. Set up a remote backend before the second person touches the repo.

# S3 with native locking (Terraform 1.10+ — no DynamoDB table needed) terraform { backend "s3" { bucket = "mycompany-tfstate" key = "prod/network/terraform.tfstate" region = "us-east-1" encrypt = true use_lockfile = true } } # Azure terraform { backend "azurerm" { resource_group_name = "tfstate-rg" storage_account_name = "mycompanytfstate" container_name = "tfstate" key = "prod.terraform.tfstate" } } # GCS terraform { backend "gcs" { bucket = "mycompany-tfstate" prefix = "prod/network" } }
Backend requirements
  • Versioning on. It's your only undo when state is corrupted or a resource is wrongly removed.
  • Encryption at rest on. State contains secrets in plaintext — see below.
  • Locking on. S3 supports native lockfiles from 1.10; older setups need a DynamoDB table.
  • One state per environment, and split large environments by blast radius (network, data, apps).
  • Restrict who can read the state bucket as tightly as you restrict production credentials — because that's what it holds.
State contains your secrets in plaintext
  • Database passwords, generated keys, and certificates are all stored unencrypted inside the state JSON.
  • sensitive = true only hides a value from CLI output. It does nothing to the state file.
  • Never commit .tfstate to git. Never post it in a ticket or a chat message.
  • Generate credentials outside Terraform where you can, and reference them from a secret manager.
  • Treat read access to state as equivalent to production admin.
When state and reality disagree
  • Back up first: terraform state pull > backup-$(date +%s).tfstate.
  • Someone deleted a resource by hand → terraform apply recreates it. That's the intended behaviour.
  • A resource exists but isn't tracked → an import block, then plan to confirm no diff.
  • Tracked but should be left alone → terraform state rm removes it from management without destroying it.
  • Someone changed settings by hand → the plan shows a diff reverting them. Decide whether to codify the change or undo it; don't just ignore_changes reflexively.
  • A stuck lock after a crashed run → verify no CI job is running, then force-unlock with the ID from the error.

Gotchas

Workspaces are not environments
  • terraform workspace gives multiple states from one configuration — every workspace runs identical code.
  • Real environments differ in instance sizes, replica counts, and which resources exist at all. Expressing that with count = terraform.workspace == "prod" ? 3 : 1 gets unreadable fast.
  • Worse, the same credentials and backend apply to every workspace, so there's no isolation between dev and prod.
  • Use separate directories with separate backends instead. Workspaces are genuinely useful for short-lived parallel copies — a per-PR test stack.
Things that bite
  • Commit .terraform.lock.hcl. Without it, CI can resolve a different provider version than you tested, and plans differ for no visible reason.
  • Never edit .tfstate by hand. Use the state subcommands; hand-editing corrupts it in ways that are painful to unwind.
  • -target skips the dependency graph, so it can leave state inconsistent. Emergency use only.
  • Some attribute changes force replacement. Read every -/+ destroy and then create replacement line in a plan — that's how databases get deleted.
  • prevent_destroy on stateful resources is cheap insurance. Note it blocks destroy entirely, including intentional ones.
  • ignore_changes hides real drift. Use it deliberately and narrowly, never on a whole resource.
  • Provider upgrades can change defaults. Read the changelog and run a plan before merging.
  • Data sources re-evaluate every plan, so a data source reading a mutable value produces perpetual diffs.
  • OpenTofu is the open-source fork created after HashiCorp's BUSL licence change; it remains largely drop-in compatible. Worth knowing which one your organisation is on.

Tips

Always save the plan

plan -out=tf.plan then apply tf.plan. Guarantees you apply exactly what was reviewed — infrastructure can change between plan and apply otherwise.

Live in terraform console

An interactive REPL with your real state loaded. Test a for expression or a function before committing it, instead of discovering the error in CI.

moved blocks over state mv

A moved block is code — reviewed, versioned, and applied consistently by everyone. state mv is a one-off local action nobody else sees.

Scan before you apply

tfsec or Checkov catch public buckets and open security groups in CI. TFLint catches invalid instance types before the API rejects them.

Cost diff in pull requests

Infracost posts the monthly cost delta on each PR. Changes the conversation about instance sizing entirely.

Version-switch with tfenv

tfenv manages multiple Terraform versions per project, matching whatever required_version says.

Resources