Back to All Cheatsheet Libraries
cheatsheets
CLI command reference, core concepts, and a safe-change workflow for Terraform.
| Block | What it does | Notes |
|---|---|---|
| resource | Creates and manages a real object. | The only block that actually makes things exist. |
| data | Reads something that already exists. | Read-only. Re-evaluated every plan, so it can cause unexpected diffs. |
| variable | An input to the module. | Always set type and description. Add validation for constrained values. |
| output | A value exposed to the caller or the CLI. | Mark credentials sensitive = true — though see the state warning below. |
| locals | Named expressions within a module. | For readability and to avoid repeating a computed value. |
| module | Calls a reusable group of resources. | Always pin the version for remote modules. |
| provider | Configures a target platform — AWS, Azure, GCP, Kubernetes. | Use alias for multiple regions or accounts. |
| terraform | Settings: backend, required versions, provider constraints. | Pin both required_version and every provider version. |
| moved | Records that a resource was renamed. | Refactor without destroy/recreate — far safer than state mv. |
| import | Brings an existing object under management, declaratively. | Terraform 1.5+. Reviewable in a plan, unlike terraform import. |
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 |
|---|---|---|
| Core | terraform init | Download providers and modules, configure the backend. Run after any change to either. |
| Core | terraform init -upgrade | Re-resolve versions within your constraints and update the lock file. |
| Core | terraform init -reconfigure | Change backends without attempting to migrate existing state. |
| Core | terraform plan -out=tf.plan | Always save the plan. Applying a saved plan guarantees you apply exactly what you reviewed. |
| Core | terraform apply tf.plan | Apply a saved plan. No second prompt — it's already approved. |
| Core | terraform destroy | Tear everything down. -target to limit scope. |
| Core | terraform fmt -recursive | Canonical formatting. Add -check in CI. |
| Core | terraform validate | Syntax and internal consistency. No provider calls, so it's fast and offline. |
| Plan | terraform plan -target=aws_instance.web | Limit to one resource. An escape hatch, not a workflow — it skips dependency checks. |
| Plan | terraform plan -refresh=false | Skip re-reading real infrastructure. Much faster on large states. |
| Plan | terraform show -json tf.plan | Machine-readable plan — the basis for policy checks in CI. |
| State | terraform state list | Every tracked resource address. |
| State | terraform state show ADDR | Full attributes of one resource as recorded in state. |
| State | terraform state mv SRC DST | Rename in state. Prefer a moved block — it's reviewable and version-controlled. |
| State | terraform state rm ADDR | Stop managing without destroying. The object keeps existing, untracked. |
| State | terraform state pull > backup.tfstate | Do this before any state surgery. |
| State | terraform force-unlock LOCK_ID | Clear a stuck lock. Verify nothing is actually running first. |
| Import | terraform import ADDR REAL_ID | Legacy imperative import. Writes state immediately, unreviewable. |
| Import | terraform plan -generate-config-out=gen.tf | With an import block, generates the HCL for you. Terraform 1.5+. |
| Debug | terraform console | Interactive REPL for testing expressions against real state. Badly underused. |
| Debug | terraform graph | dot -Tsvg > g.svg | Visualise the dependency graph. |
| Debug | TF_LOG=DEBUG terraform apply | Verbose logs including provider API calls. TF_LOG_PATH to write to a file. |
| Workspace | terraform workspace list / select NAME | Multiple states from one config. Not a good fit for prod vs dev — see Gotchas. |
# 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 countcountindexes by position:aws_instance.web[0]. Removing the first element renumbers every later one, so Terraform destroys and recreates them all.for_eachindexes by key:aws_instance.web["api"]. Removing one entry touches only that one.- Use
for_eachfor anything list-like. Reservecountfor 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 consolebefore committing them.
# 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 = trueonly hides a value from CLI output. It does nothing to the state file.- Never commit
.tfstateto 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 applyrecreates it. That's the intended behaviour. - A resource exists but isn't tracked → an
importblock, then plan to confirm no diff. - Tracked but should be left alone →
terraform state rmremoves 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_changesreflexively. - A stuck lock after a crashed run → verify no CI job is running, then
force-unlockwith the ID from the error.
Gotchas
Workspaces are not environments
terraform workspacegives 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 : 1gets 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
.tfstateby hand. Use thestatesubcommands; hand-editing corrupts it in ways that are painful to unwind. -targetskips the dependency graph, so it can leave state inconsistent. Emergency use only.- Some attribute changes force replacement. Read every
-/+ destroy and then create replacementline in a plan — that's how databases get deleted. prevent_destroyon stateful resources is cheap insurance. Note it blocksdestroyentirely, including intentional ones.ignore_changeshides 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 consoleAn 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 mvA 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
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
Terraform documentation
Official docs — language reference, CLI, and backends.
developer.hashicorp.com
Function reference
Every built-in function with examples. Pair it with
terraform console.
developer.hashicorp.com
Terraform Registry
Providers and community modules. Check recent activity before adopting a module.
registry.terraform.io
Official style guide
HashiCorp's own conventions for file layout, naming, and structure.
developer.hashicorp.com
OpenTofu
The MPL-licensed fork. Largely drop-in compatible — worth knowing which one you're on.
opentofu.org
Infracost
Monthly cost delta posted directly on pull requests.
infracost.io
AWS
The most common Terraform target — the CLI equivalents of what you're declaring.
build.ty1er.com
Kubernetes
Often what Terraform provisions, and where the workloads then live.
build.ty1er.com