Terraform Cheatsheet
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 AWSSetup
# 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 anythingInit & 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 -upgradePlan, 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 doingState 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 scannerOutputs
# 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-JsonImport Existing Resources
# Modern (Terraform 1.5+): declarative import via import block in .tf filesimport {
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-nameDebugging & 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 versionsProviders & 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 / OIDCCommon 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_eachovercount— resources keyed by string are stable across list reorderings, resources keyed by count are not. - Never put credentials in
.tffiles. Read from env vars, SSO, or OIDC federation..tfvarsfiles are fine for non-secret config, but add them to.gitignoreif they contain anything sensitive. terraform_remote_stateis 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 destroyis a testing tool, not a production operation. Real environments should remove resources by deleting their.tfand applying, not bydestroy.- Terraform workspaces (
terraform workspace new prod) share a backend and module set. For real environment separation, use directory-per-env instead. - Run
terraform fmt -recursivebefore every commit. Make it a pre-commit hook if you keep forgetting. - Add
terraform plan -out=tfplan && terraform apply tfplanto 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 -200is often the fastest way to see what AWS API call is actually failing, when the top-level error message isn’t clear.