Containers and IaC
Terraform
Plan and apply mechanics, state operations, module and variable structure, and recovering from drift or a broken state.
Cheatsheet #
| Task | Command |
|---|---|
| Initialise, get providers | terraform init |
| Re-init after backend change | terraform init -migrate-state |
| Format and validate | terraform fmt -recursive && terraform validate |
| Plan to a file | terraform plan -out=tfplan |
| Apply exactly that plan | terraform apply tfplan |
| Target one resource | terraform plan -target=aws_instance.web |
| Show current state | terraform show |
| State as JSON | terraform show -json | jq |
| List resources in state | terraform state list |
| Inspect one | terraform state show aws_instance.web |
| Adopt an existing resource | terraform import aws_instance.web i-0abc123 |
| Forget without destroying | terraform state rm aws_instance.web |
| Rename in state | terraform state mv a.b a.c |
| Recreate one resource | terraform apply -replace=aws_instance.web |
| Unlock a stuck state | terraform force-unlock <lock-id> |
| Outputs for scripts | terraform 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' -rAlways 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-argument | Effect |
|---|---|
count | Indexed instances; removing one in the middle re-indexes everything after it |
for_each | Keyed instances; adding or removing a key touches only that key |
depends_on | Explicit ordering when the dependency is not visible in an expression |
lifecycle.prevent_destroy | Fails the plan rather than deleting; use on databases |
lifecycle.ignore_changes | Hands a field to something else |
provider | Pick 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 goneSince 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 listTroubleshooting #
| Symptom | Fix |
|---|---|
Error acquiring the state lock | Another apply is running, or it crashed: verify, then force-unlock |
| Plan wants to replace everything | Provider version jump, or count index shift after a list change |
Provider produced inconsistent result | Provider bug or an API that mutates the value; ignore_changes as a workaround |
| Resource exists but Terraform wants to create it | Not in state — import it |
| Resource gone but Terraform wants to update it | Deleted outside Terraform — apply -refresh-only then plan again |
| Cycle error | A depends_on loop, often between security groups; use rule resources instead |
| Slow plans | Too 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.svgOneliners #
# 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