Software Engineering Wiki

Containers and IaC

Terraform

Plan and apply mechanics, state operations, module and variable structure, and recovering from drift or a broken state.

Cheatsheet #

TaskCommand
Initialise, get providersterraform init
Re-init after backend changeterraform init -migrate-state
Format and validateterraform fmt -recursive && terraform validate
Plan to a fileterraform plan -out=tfplan
Apply exactly that planterraform apply tfplan
Target one resourceterraform plan -target=aws_instance.web
Show current stateterraform show
State as JSONterraform show -json | jq
List resources in stateterraform state list
Inspect oneterraform state show aws_instance.web
Adopt an existing resourceterraform import aws_instance.web i-0abc123
Forget without destroyingterraform state rm aws_instance.web
Rename in stateterraform state mv a.b a.c
Recreate one resourceterraform apply -replace=aws_instance.web
Unlock a stuck stateterraform force-unlock <lock-id>
Outputs for scriptsterraform output -json | jq -r .url.value

How it works #

Terraform builds a dependency graph from the configuration, compares the desired graph with state, then proposes the minimum set of create, update and destroy actions. State is the record of what Terraform believes exists, mapping addresses to real resource IDs — it is not a cache, it is the source of identity.

Three things can disagree: configuration, state, and reality. plan shows configuration versus state; drift detection (plan -refresh-only) shows state versus reality.

terraform plan -refresh-only        # what changed outside Terraform
terraform apply -refresh-only       # accept reality into state without changing infrastructure
terraform plan -out=tfplan && terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions[0]!="no-op") | [.address, .change.actions[0]] | @tsv' -r

Always plan -out then apply <file> in automation: applying without a saved plan re-plans at apply time, so what you reviewed is not necessarily what runs.

Configuration #

terraform {
  required_version = ">= 1.9"
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.60" }
  }
  backend "s3" {
    bucket         = "example-tfstate"
    key            = "prod/network/terraform.tfstate"
    region         = "ap-southeast-2"
    dynamodb_table = "tfstate-locks"    # state locking
    encrypt        = true
  }
}

resource "aws_instance" "web" {
  count                  = var.instance_count
  ami                    = data.aws_ami.al2023.id
  instance_type          = var.instance_type
  subnet_id              = element(var.subnet_ids, count.index)
  vpc_security_group_ids = [aws_security_group.web.id]

  tags = merge(var.tags, { Name = "${var.name}-${count.index}" })

  lifecycle {
    create_before_destroy = true
    ignore_changes        = [ami]          # let an image pipeline own this
    precondition {
      condition     = var.instance_type != "t2.micro"
      error_message = "t2.micro is not permitted in production."
    }
  }
}
Meta-argumentEffect
countIndexed instances; removing one in the middle re-indexes everything after it
for_eachKeyed instances; adding or removing a key touches only that key
depends_onExplicit ordering when the dependency is not visible in an expression
lifecycle.prevent_destroyFails the plan rather than deleting; use on databases
lifecycle.ignore_changesHands a field to something else
providerPick an aliased provider for multi-region or multi-account

Prefer for_each over count for anything with a natural key. count ties identity to position, so deleting the first element of a list destroys and recreates every resource after it.

resource "aws_subnet" "this" {
  for_each          = var.subnets              # map(string) of name => cidr
  vpc_id            = aws_vpc.this.id
  cidr_block        = each.value
  availability_zone = "${var.region}${each.key}"
}

Variables and outputs #

variable "instance_type" {
  type        = string
  default     = "t3.small"
  description = "EC2 instance size for the web tier"
  validation {
    condition     = can(regex("^t3\\.", var.instance_type))
    error_message = "Only t3 sizes are approved here."
  }
}

variable "db_password" {
  type      = string
  sensitive = true      # kept out of CLI output, still plain text in state
}

output "url" {
  value       = "https://${aws_lb.this.dns_name}"
  description = "Public endpoint"
}

Precedence, lowest to highest: the variable’s default, TF_VAR_* environment variables, terraform.tfvars, terraform.tfvars.json, *.auto.tfvars in alphabetical order, then -var and -var-file in the order given on the command line.

State contains secrets in plain text

Any sensitive value passed to a resource is stored unencrypted in state. Encrypt the backend, restrict access to it, and never commit terraform.tfstate or *.tfvars holding credentials.

Modules #

module "network" {
  source = "git::https://github.com/example/tf-modules.git//network?ref=v1.4.0"

  name     = "prod"
  cidr     = "10.20.0.0/16"
  az_count = 3
}

resource "aws_instance" "web" {
  subnet_id = module.network.private_subnet_ids[0]
}

Pin module sources to a tag or commit. A floating ref=main means a plan can change because someone else merged, which removes the property that makes Terraform reviewable.

Keep modules thin: inputs, resources, outputs. A module that decides policy (naming, tagging, environments) is harder to reuse than one that accepts those as variables.

State operations #

terraform state list
terraform state show aws_instance.web
terraform state mv 'aws_instance.web' 'aws_instance.api'          # rename after refactoring
terraform state mv 'module.a.aws_s3_bucket.b' 'module.c.aws_s3_bucket.b'
terraform state rm aws_instance.legacy                             # stop managing, keep the resource
terraform import aws_instance.web i-0abc123                        # adopt an existing resource
terraform state pull > backup.tfstate                              # always, before surgery
terraform force-unlock 1a2b3c4d-...                                # only when the holder is definitely gone

Since Terraform 1.5, import blocks make adoption reviewable and repeatable:

import {
  to = aws_instance.web
  id = "i-0abc123"
}

terraform plan -generate-config-out=generated.tf then writes matching configuration for imported resources.

Workspaces and environments #

Workspaces switch state files within one backend key prefix. They suit ephemeral copies of identical infrastructure, not prod-versus-dev differences, which belong in separate directories with separate backends and separate credentials.

terraform workspace new feature-x
terraform workspace select default
terraform workspace list

Troubleshooting #

SymptomFix
Error acquiring the state lockAnother apply is running, or it crashed: verify, then force-unlock
Plan wants to replace everythingProvider version jump, or count index shift after a list change
Provider produced inconsistent resultProvider bug or an API that mutates the value; ignore_changes as a workaround
Resource exists but Terraform wants to create itNot in state — import it
Resource gone but Terraform wants to update itDeleted outside Terraform — apply -refresh-only then plan again
Cycle errorA depends_on loop, often between security groups; use rule resources instead
Slow plansToo many resources in one state; split by lifecycle and blast radius
TF_LOG=DEBUG terraform plan 2>debug.log     # provider API calls
TF_LOG_PROVIDER=TRACE terraform apply
terraform providers                          # what is actually required and where
terraform graph | dot -Tsvg > graph.svg

Oneliners #

# Everything the plan would change, one line each
terraform show -json tfplan | jq -r '.resource_changes[] | select(.change.actions != ["no-op"]) | "\(.change.actions|join(","))\t\(.address)"'

# Only destroys — the review that matters
terraform show -json tfplan | jq -r '.resource_changes[] | select(.change.actions[0]=="delete") | .address'

# Resource counts by type
terraform state list | sed 's/\[.*//' | awk -F. '{print $(NF-1)}' | sort | uniq -c | sort -rn

# Outputs into environment variables
eval "$(terraform output -json | jq -r 'to_entries[] | "export TF_\(.key|ascii_upcase)=\(.value.value|@sh)"')"

# Find hardcoded values that should be variables
grep -rnE '"(10\.[0-9]+\.|ami-|arn:aws)' --include='*.tf' .

# Format check in CI
terraform fmt -check -recursive -diff

# Validate every module directory
find . -name '*.tf' -printf '%h\n' | sort -u | xargs -n1 -I{} sh -c 'cd {} && terraform validate >/dev/null && echo "ok {}"'

# Which provider versions are locked
jq -r '.provider[] | "\(.source) \(.version)"' .terraform.lock.hcl 2>/dev/null || grep -A2 'provider ' .terraform.lock.hcl

# Back up state before anything risky
terraform state pull > "state-$(date +%Y%m%dT%H%M%S).json"

# Diff state against reality without changing anything
terraform plan -refresh-only -no-color | sed -n '/has changed/,/^$/p'

# Replace a resource on the next apply
terraform apply -replace='aws_instance.web[0]'

# Destroy only a module
terraform destroy -target=module.sandbox

Last updated 15 September 2026 · Edit this page