Terraform Cheatsheet

Terraform
IaC
AWS
Cheatsheet
Command Line
Note
Author

Louis Becker

Published

July 13, 2026

Notes to self on the Terraform patterns I actually use. Assumes Terraform 1.9+ and the AWS provider (hashicorp/aws ~> 5.0). Some patterns generalise to other providers.

Quick Reference - Most Used Commands

# Format, plan, apply — the daily loop
terraform fmt -recursive              # Format all .tf files in place
terraform fmt -check -recursive       # CI-style check, fails if not formatted
terraform init                        # First time in a stack, or after backend/provider changes
terraform init -reconfigure           # After backend.tf changed (no state move)
terraform init -migrate-state         # Move state between backends
terraform init -upgrade               # Bump provider versions per version constraints
terraform plan                        # Show what would change
terraform plan -out=tfplan            # Save plan to file (belt-and-braces for prod)
terraform apply                       # Apply with interactive confirmation
terraform apply tfplan                # Apply exactly the saved plan
terraform apply -auto-approve         # No prompt (what CI runs; rarely local)
terraform validate                    # Syntax/config check without hitting AWS

Setup

# Check installed version
terraform version                     # Shows CLI + provider versions

# Pin the version project-wide with .tool-versions (asdf) or .terraform-version (tfenv)
echo "terraform 1.14.8" > .tool-versions

# Which providers is the current stack actually using?
terraform providers                   # Tree of providers + versions

# What's the target account? (AWS-specific)
aws sts get-caller-identity           # Verify before applying anything

Init & Backend

# First-time init (creates .terraform/ and downloads providers)
terraform init

# After changing backend.tf: pick the right flag
terraform init -reconfigure           # Backend config changed, state stays in place
terraform init -migrate-state         # Actually moving state to a new backend
terraform init -upgrade               # Providers only — update within version constraints

# After deleting .terraform/ (nuke-and-repave)
terraform init                        # Rebuilds from lock file

# NEVER delete .terraform.lock.hcl by hand — it pins provider versions.
# If you want to bump a provider version, edit versions.tf then:
terraform init -upgrade

Plan, Apply, Destroy

# Plan the diff
terraform plan                        # Interactive output
terraform plan -out=tfplan            # Save plan to a file
terraform plan -target=aws_s3_bucket.example    # Plan a single resource (last resort)
terraform plan -var="env=prod"        # Override a variable inline
terraform plan -var-file=prod.tfvars  # Load variables from a file
terraform plan -refresh-only          # Just refresh state, no config diff
terraform plan -destroy               # Preview a destroy

# Apply
terraform apply                       # Plan + prompt + apply
terraform apply tfplan                # Apply a saved plan (deterministic)
terraform apply -auto-approve         # No confirmation prompt

# Destroy (rarely — see below on prevent_destroy)
terraform destroy                     # Interactive prompt
terraform destroy -target=aws_s3_bucket.example    # Destroy one resource
terraform destroy -auto-approve       # Dangerous, know what you're doing

State Operations

# Read
terraform state list                  # All resources currently tracked
terraform state list | grep s3        # Filter
terraform state show aws_s3_bucket.example    # Full details for one resource
terraform state pull                  # Dump state as JSON to stdout
terraform state pull | jq '.resources[].type'  # Grep types

# Move / rename (address changes without recreating the AWS resource)
terraform state mv aws_iam_role.old aws_iam_role.new
terraform state mv module.foo.aws_s3_bucket.a module.bar.aws_s3_bucket.a

# Forget a resource (keeps it in AWS, drops it from state)
terraform state rm aws_s3_bucket.unmanaged

# Adopt an existing AWS resource into Terraform (see: import section below)
terraform import aws_s3_bucket.existing my-bucket-name

# Push state back after manual edits (dangerous, hardly ever)
terraform state push state.json

# Rule of thumb: `state list` before `state rm`. Read before write.

Refactoring — the moved block

# Renaming a resource without state surgery. Introduced in Terraform 1.1.
moved {
  from = aws_s3_bucket.old_name
  to   = aws_s3_bucket.new_name
}

# Terraform reads this on plan/apply and updates state addresses in place.
# Once applied, delete the `moved` block. Beats `terraform state mv` because
# it's checked into version control and reviewed like any other change.

Formatting & Validation

terraform fmt                         # Format files in current directory
terraform fmt -recursive              # Format entire tree
terraform fmt -check                  # Exit 0 if formatted, 1 if not (CI use)
terraform fmt -diff                   # Show what would change

terraform validate                    # Config valid? (no AWS calls)

# Third-party tools that pair well:
tflint                                # Lint (catches provider-specific issues)
tfsec                                 # Security-focused lint
checkov                               # Broader security + compliance scanner

Outputs

# View outputs of the current stack
terraform output                      # All outputs
terraform output name_servers         # Specific one
terraform output -json                # Machine-readable
terraform output -raw name_servers    # No quotes, no formatting — for scripting

# PowerShell example: capture output into a variable
$ns = terraform output -json name_servers | ConvertFrom-Json

Import Existing Resources

# Modern (Terraform 1.5+): declarative import via import block in .tf files
import {
  to = aws_s3_bucket.example
  id = "my-existing-bucket-name"
}

resource "aws_s3_bucket" "example" {
  bucket = "my-existing-bucket-name"
  # ... rest of the config that matches what AWS already has
}
# Then run:
terraform plan                        # Confirms Terraform would adopt without changing anything
terraform apply                       # Actually imports

# Legacy (still supported): terraform import CLI command
terraform import aws_s3_bucket.example my-existing-bucket-name

Debugging & Logging

# Verbose logs (TRACE | DEBUG | INFO | WARN | ERROR)
export TF_LOG=DEBUG                   # Bash
$env:TF_LOG = "DEBUG"                 # PowerShell
terraform plan
unset TF_LOG                          # Bash
Remove-Item Env:TF_LOG                # PowerShell

# Log to a file instead of the console
export TF_LOG_PATH=tf-debug.log

# TRACE is very noisy — pipe through head to keep it usable
TF_LOG=TRACE terraform plan 2>&1 | head -200

# What's Terraform actually calling?
terraform providers                   # Provider tree for the stack
terraform version                     # CLI + all provider versions

Providers & Variables

# variables.tf — declare
variable "region" {
  description = "Primary AWS region"
  type        = string
  default     = "eu-west-1"
}

variable "account_id" {
  description = "12-digit AWS account ID"
  type        = string

  validation {
    condition     = can(regex("^[0-9]{12}$", var.account_id))
    error_message = "account_id must be a 12-digit AWS account ID."
  }
}

# Use in provider config
provider "aws" {
  region              = var.region
  allowed_account_ids = [var.account_id]    # Refuses to run in wrong account
}

# Aliased provider — for CloudFront ACM (us-east-1 required), Route 53 DNSSEC, etc.
provider "aws" {
  alias  = "us_east_1"
  region = "us-east-1"
}

# Use with alias on a resource
resource "aws_acm_certificate" "site" {
  provider          = aws.us_east_1
  domain_name       = "example.com"
  validation_method = "DNS"
}
# Passing variables at the CLI
terraform plan -var="region=us-west-2"
terraform plan -var-file=prod.tfvars   # File with `region = "us-west-2"` etc.

Modules

# Using a local module
module "static_site" {
  source = "../../../modules/static-site"

  domain_name = "example.com"
  zone_id     = "Z01234567890ABCDEF"

  # Pass aliased providers explicitly
  providers = {
    aws           = aws
    aws.us_east_1 = aws.us_east_1
  }
}

# Using a registry module
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"

  name = "my-vpc"
  cidr = "10.0.0.0/16"
}

# Using a Git module (pin to a tag/commit, never `main`)
module "example" {
  source = "git::https://github.com/org/repo.git//path?ref=v1.2.3"
}

Data Sources — common one-liners

# Current account/user
data "aws_caller_identity" "current" {}
# → data.aws_caller_identity.current.account_id
# → data.aws_caller_identity.current.arn

# Current region
data "aws_region" "current" {}
# → data.aws_region.current.name

# Partition (usually "aws", could be "aws-us-gov" or "aws-cn")
data "aws_partition" "current" {}
# → data.aws_partition.current.partition

# Available AZs in the region
data "aws_availability_zones" "available" {
  state = "available"
}

# Latest Amazon Linux 2023 AMI
data "aws_ami" "al2023" {
  most_recent = true
  owners      = ["amazon"]
  filter {
    name   = "name"
    values = ["al2023-ami-*-x86_64"]
  }
}

Cross-stack references

# Read outputs from another stack's state file
data "terraform_remote_state" "network" {
  backend = "s3"

  config = {
    bucket = "my-terraform-state-bucket"
    key    = "stacks/prod/network/terraform.tfstate"
    region = "eu-west-1"
  }
}

# Use them
resource "aws_instance" "example" {
  subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
  # ...
}

Useful Patterns

S3 backend with native locking (Terraform 1.9+)

terraform {
  backend "s3" {
    bucket       = "my-terraform-state-bucket"
    key          = "stacks/prod/network/terraform.tfstate"
    region       = "eu-west-1"
    use_lockfile = true      # No more DynamoDB lock table
  }
}

use_lockfile = true replaces the old DynamoDB locking pattern (native S3 lock file). If you’re migrating an older stack, drop the dynamodb_table line and add use_lockfile = true, then terraform init -reconfigure.

prevent_destroy for critical resources

resource "aws_s3_bucket" "state_bucket" {
  bucket = "critical-terraform-state"

  lifecycle {
    prevent_destroy = true    # Refuses any operation that would destroy this
  }
}

To actually remove a prevent_destroy resource:

# Option A (two-step, safest for catastrophic resources):
# 1. Edit the file: set prevent_destroy = false
# 2. terraform apply       (no-op in AWS, just config-side lifecycle update)
# 3. Delete the resource block entirely
# 4. terraform apply       (destroys)

# Option B (one-step, works for less-critical resources):
# 1. Delete the resource block (lifecycle goes with it)
# 2. terraform apply       (destroys in one operation)

for_each for iterated resources

resource "aws_s3_object" "site_files" {
  for_each = fileset("${path.module}/content", "**/*")

  bucket = aws_s3_bucket.site.id
  key    = each.value
  source = "${path.module}/content/${each.value}"
  etag   = filemd5("${path.module}/content/${each.value}")
}

dynamic blocks for repeated nested configs

resource "aws_security_group" "example" {
  name = "example"

  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"]
    }
  }
}

Cross-account resource policy — account-root + PrincipalArn pattern

Reference an IAM role in another account without needing that role to already exist. AWS validates policies at write-time and rejects references to non-existent principals — but the account root always exists, and aws:PrincipalArn narrows the grant.

data "aws_iam_policy_document" "cross_account" {
  statement {
    principals {
      type        = "AWS"
      identifiers = ["arn:aws:iam::123456789012:root"]    # The whole account
    }
    actions   = ["s3:GetObject"]
    resources = ["${aws_s3_bucket.example.arn}/*"]
    condition {
      test     = "StringEquals"
      variable = "aws:PrincipalArn"
      values = [
        "arn:aws:iam::123456789012:role/my-future-role",   # OK if this doesn't exist yet
      ]
    }
  }
}

Useful for pre-granting access to roles that will be created later (e.g. an OIDC role in a downstream account you haven’t set up yet).

Locals for computed values

locals {
  environment = terraform.workspace       # Or hardcode based on stack layout
  common_tags = {
    Environment = local.environment
    ManagedBy   = "terraform"
    Project     = "my-project"
  }
  bucket_name = "myapp-${local.environment}-${data.aws_caller_identity.current.account_id}"
}

Environment Variables

TF_LOG=DEBUG                          # Log level (TRACE, DEBUG, INFO, WARN, ERROR)
TF_LOG_PATH=tf-debug.log              # Log to file instead of stdout
TF_VAR_region=us-east-1               # Set variable `region` (same as -var)
TF_CLI_ARGS="-no-color"               # Global flags for every terraform call
TF_CLI_ARGS_plan="-parallelism=5"     # Flags only for `plan`
TF_DATA_DIR=.terraform                # Location of .terraform/ (rarely change)
TF_INPUT=0                            # Disable interactive prompts (for CI)

# AWS provider reads these
AWS_PROFILE=my-profile
AWS_REGION=us-east-1
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY   # Avoid static keys; use SSO / OIDC

Common Errors & Fixes

# "Backend configuration changed"
# → You edited backend.tf. Just refresh the local cache:
terraform init -reconfigure

# "Failed to reload plugin schemas" / provider version mismatches
# → Providers on disk don't match versions.tf. Pull latest allowed versions:
terraform init -upgrade

# "MalformedPolicy: Invalid principal in policy"  (S3/DynamoDB/KMS resource policy)
# → You're referencing a role/user ARN that doesn't exist yet.
# → Use the account-root + PrincipalArn pattern (see above).

# "Error: Provider produced inconsistent final plan"
# → Bug in a provider. Try upgrading the provider first, then file an issue.

# "Error: instance already exists"  (on import)
# → State already tracks the resource. Use `terraform state list` to check.

# "The current .terraform.lock.hcl file only includes checksums for X"
# → Add checksums for additional platforms (Linux, macOS, Windows):
terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 -platform=windows_amd64

# Plan says "No changes" but you're sure there ARE changes
# → Someone (or you) applied out-of-band. Check `terraform state list` and
#   compare against expected resources. Or you're in the wrong directory /
#   wrong AWS profile.

Helpful Tips

  • Prefer for_each over count — resources keyed by string are stable across list reorderings, resources keyed by count are not.
  • Never put credentials in .tf files. Read from env vars, SSO, or OIDC federation. .tfvars files are fine for non-secret config, but add them to .gitignore if they contain anything sensitive.
  • terraform_remote_state is a data source, not a config-time constant. It reads state at plan time; a stale state file means stale reads.
  • Modules aren’t a substitute for good composition — anything used twice becomes a module, anything used once stays inline until the second use forces the abstraction.
  • terraform destroy is a testing tool, not a production operation. Real environments should remove resources by deleting their .tf and applying, not by destroy.
  • Terraform workspaces (terraform workspace new prod) share a backend and module set. For real environment separation, use directory-per-env instead.
  • Run terraform fmt -recursive before every commit. Make it a pre-commit hook if you keep forgetting.
  • Add terraform plan -out=tfplan && terraform apply tfplan to any pipeline that touches prod — the two-step is your defence against “plan drift” between the review and the apply.
  • TF_LOG=TRACE terraform plan 2>&1 | head -200 is often the fastest way to see what AWS API call is actually failing, when the top-level error message isn’t clear.