Terraform by HashiCorp is the most widely used infrastructure-as-code tool. It lets you define cloud resources in HCL (HashiCorp Configuration Language) and manage them with a reproducible, auditable workflow. This cheat sheet covers everything from CLI basics to production patterns.
Quick reference
The 25 patterns that cover 95% of everyday Terraform work.
| Pattern | Example |
|---|---|
| Initialize directory | terraform init |
| Preview changes | terraform plan |
| Apply changes | terraform apply |
| Destroy resources | terraform destroy |
| Format HCL files | terraform fmt |
| Validate config | terraform validate |
| Show state | terraform show |
| List state resources | terraform state list |
| Import existing resource | terraform import aws_s3_bucket.b my-bucket |
| Output values | terraform output |
| Workspace switch | terraform workspace select prod |
| Refresh state | terraform apply -refresh-only |
| Target single resource | terraform apply -target=aws_instance.web |
| String variable | variable "region" { type = string } |
| Number variable | variable "count" { type = number } |
| Object variable | variable "tags" { type = map(string) } |
| Local value | locals { name = "myapp-${var.env}" } |
| Data source | data "aws_ami" "ubuntu" { ... } |
| Output value | output "ip" { value = aws_instance.web.public_ip } |
| Module call | module "vpc" { source = "./modules/vpc" } |
| For expression | [for s in var.list : upper(s)] |
| Conditional | var.env == "prod" ? 2 : 1 |
| Dynamic block | dynamic "tag" { for_each = var.tags } |
| Lifecycle rule | lifecycle { prevent_destroy = true } |
| Provider version | version = "~> 5.0" |
CLI commands
Core workflow
# Initialize — downloads providers and modules
terraform init
# Preview what will change — always run before apply
terraform plan
# Apply changes (auto-approve skips interactive prompt)
terraform apply
terraform apply -auto-approve
# Destroy all managed resources
terraform destroy
terraform destroy -target=aws_instance.web # single resource
Planning and applying
# Save plan to file (for CI/CD pipelines)
terraform plan -out=tfplan
terraform apply tfplan
# Plan with variable overrides
terraform plan -var="region=us-east-1" -var="env=prod"
# Plan with a var-file
terraform plan -var-file="prod.tfvars"
# Apply with JSON output for parsing
terraform plan -json | jq '.resource_changes[].change.actions'
State management
terraform show # human-readable state
terraform show -json # JSON state
terraform state list # list all resources
terraform state show aws_s3_bucket.b # inspect one resource
terraform state mv aws_s3_bucket.old aws_s3_bucket.new # rename
terraform state rm aws_s3_bucket.b # remove from state (does not delete)
terraform import aws_s3_bucket.b my-bucket # import existing
Workspaces
terraform workspace list
terraform workspace new staging
terraform workspace select staging
terraform workspace show # current workspace name
terraform workspace delete staging
Utilities
terraform fmt # format all .tf files in current dir
terraform fmt -recursive # format all sub-directories
terraform validate # check syntax and internal consistency
terraform output # print all outputs
terraform output -json # JSON outputs
terraform graph | dot -Tsvg > graph.svg # dependency graph
terraform version # show versions
terraform providers # show required providers
HCL syntax
Terraform block
terraform {
required_version = ">= 1.6"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
# Remote state backend (S3 example)
backend "s3" {
bucket = "my-tf-state"
key = "global/s3/terraform.tfstate"
region = "us-east-1"
dynamodb_table = "terraform-locks"
encrypt = true
}
}
Provider configuration
provider "aws" {
region = var.aws_region
profile = "default"
default_tags {
tags = {
Project = var.project_name
Environment = var.env
ManagedBy = "terraform"
}
}
}
# Additional provider alias (e.g., second region)
provider "aws" {
alias = "us_east"
region = "us-east-1"
}
Resource block
resource "aws_s3_bucket" "website" {
bucket = "${var.project}-${var.env}-website"
tags = {
Name = "Website bucket"
}
}
# Reference another resource
resource "aws_s3_bucket_versioning" "website" {
bucket = aws_s3_bucket.website.id # <type>.<name>.<attribute>
versioning_configuration {
status = "Enabled"
}
}
Lifecycle rules
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
lifecycle {
create_before_destroy = true # zero-downtime replacement
prevent_destroy = true # block terraform destroy
ignore_changes = [tags] # don't track tag drift
}
}
Variables
Variable declaration
# String with default
variable "region" {
type = string
default = "eu-west-1"
description = "AWS region to deploy resources"
}
# Number
variable "instance_count" {
type = number
default = 1
}
# Boolean
variable "enable_monitoring" {
type = bool
default = false
}
# List
variable "availability_zones" {
type = list(string)
default = ["eu-west-1a", "eu-west-1b"]
}
# Map
variable "instance_types" {
type = map(string)
default = {
dev = "t3.micro"
prod = "t3.medium"
}
}
# Object with validation
variable "tags" {
type = object({
environment = string
owner = string
})
validation {
condition = contains(["dev", "staging", "prod"], var.tags.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
# Sensitive variable — value hidden in plan output
variable "db_password" {
type = string
sensitive = true
}
Variable files
# dev.tfvars
region = "eu-west-1"
instance_count = 1
enable_monitoring = false
# prod.tfvars
region = "us-east-1"
instance_count = 3
enable_monitoring = true
# Apply with var-file
terraform apply -var-file="prod.tfvars"
Variable precedence (lowest → highest)
| Source | Example |
|---|---|
Default value in variable block |
default = "us-east-1" |
terraform.tfvars (auto-loaded) |
region = "eu-west-1" |
*.auto.tfvars (auto-loaded) |
prod.auto.tfvars |
-var-file flag |
-var-file=prod.tfvars |
-var flag |
-var="region=us-east-1" |
| Environment variable | TF_VAR_region=us-east-1 |
Locals and outputs
Locals
locals {
# Derived values
name_prefix = "${var.project}-${var.env}"
is_prod = var.env == "prod"
common_tags = merge(var.tags, { ManagedBy = "terraform" })
# Computed map
instance_type = {
dev = "t3.micro"
prod = "t3.large"
}[var.env]
}
# Use locals
resource "aws_instance" "web" {
instance_type = local.instance_type
tags = local.common_tags
}
Outputs
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
output "db_endpoint" {
description = "RDS endpoint address"
value = aws_db_instance.main.endpoint
sensitive = true # hidden in terminal, still readable via terraform output
}
# Output from a module
output "alb_dns" {
value = module.alb.dns_name
}
Data sources
# Fetch latest Ubuntu AMI
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-*-22.04-amd64-server-*"]
}
}
# Fetch existing VPC by tag
data "aws_vpc" "main" {
tags = { Name = "main-vpc" }
}
# Read a local file
data "local_file" "userdata" {
filename = "${path.module}/userdata.sh"
}
# Fetch SSM parameter (e.g., secrets)
data "aws_ssm_parameter" "db_password" {
name = "/myapp/db/password"
with_decryption = true
}
Expressions and functions
Conditionals and for expressions
# Ternary
instance_count = var.env == "prod" ? 3 : 1
# For expression — list
upper_zones = [for z in var.azs : upper(z)]
# For expression — map (filter)
prod_only = { for k, v in var.config : k => v if v.env == "prod" }
# Splat expression
all_ids = aws_instance.web[*].id
String functions
lower("HELLO") # → "hello"
upper("hello") # → "HELLO"
format("%.2f", 1.234) # → "1.23"
join(", ", ["a","b"]) # → "a, b"
split(",", "a,b,c") # → ["a", "b", "c"]
trimspace(" hi ") # → "hi"
replace("a-b", "-", "_") # → "a_b"
substr("hello", 0, 3) # → "hel"
Collection functions
length(var.list) # list/map/string length
contains(var.list, "prod") # membership check
toset(["a", "b", "a"]) # → {"a", "b"}
flatten([["a", "b"], ["c"]]) # → ["a", "b", "c"]
merge(map1, map2) # combine maps (right wins)
keys(var.map) # list of keys
values(var.map) # list of values
lookup(var.map, "key", "default") # safe map access
coalesce(var.x, var.y, "fallback") # first non-null
Dynamic blocks
resource "aws_security_group" "web" {
name = "web-sg"
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"]
}
}
}
Modules
Calling a module
module "vpc" {
source = "./modules/vpc" # local module
# source = "terraform-aws-modules/vpc/aws" # Terraform Registry
# source = "git::https://github.com/org/repo.git//modules/vpc?ref=v1.2.0"
version = "~> 5.0" # Registry modules only
cidr_block = "10.0.0.0/16"
availability_zones = var.azs
private_subnets = ["10.0.1.0/24", "10.0.2.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24"]
}
# Reference module output
resource "aws_lb" "main" {
subnets = module.vpc.public_subnet_ids
}
Writing a module
modules/vpc/
main.tf # resources
variables.tf # input variables
outputs.tf # output values
versions.tf # required_providers
README.md
# modules/vpc/variables.tf
variable "cidr_block" {
type = string
description = "CIDR block for the VPC"
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
State management
Remote state
# Store state in S3 (recommended for teams)
terraform {
backend "s3" {
bucket = "mycompany-terraform-state"
key = "services/api/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-state-lock"
}
}
# Read another stack's outputs via remote state
data "terraform_remote_state" "network" {
backend = "s3"
config = {
bucket = "mycompany-terraform-state"
key = "network/terraform.tfstate"
region = "us-east-1"
}
}
resource "aws_instance" "app" {
subnet_id = data.terraform_remote_state.network.outputs.private_subnet_id
}
State commands
# Safe state surgery
terraform state list # see all resources
terraform state show aws_instance.web # inspect one
terraform state mv aws_instance.web aws_instance.app # rename
terraform state rm aws_s3_bucket.old # untrack (doesn't delete)
terraform import aws_s3_bucket.existing my-bucket # adopt existing resource
# Move resources between state files (Terraform 1.1+)
terraform state pull > state.json # download raw state
terraform state push state.json # upload (dangerous!)
Workspaces
Workspaces share the same configuration but maintain separate state files — useful for environment separation without separate directories.
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
terraform workspace select prod
terraform workspace show # → "prod"
# Use workspace name in resources
resource "aws_instance" "web" {
count = terraform.workspace == "prod" ? 3 : 1
instance_type = terraform.workspace == "prod" ? "t3.large" : "t3.micro"
}
Tip: For large teams, prefer separate directories with separate backends over workspaces. Workspaces share the same code — a typo in prod is a typo everywhere.
Common patterns
Count and for_each
# count — identical resources
resource "aws_instance" "web" {
count = var.instance_count
ami = data.aws_ami.ubuntu.id
instance_type = "t3.micro"
tags = { Name = "web-${count.index}" }
}
# for_each — distinct resources keyed by map/set
resource "aws_iam_user" "devs" {
for_each = toset(var.dev_usernames)
name = each.key
}
# for_each with objects
resource "aws_route53_record" "dns" {
for_each = {
www = "1.2.3.4"
api = "5.6.7.8"
}
name = each.key
records = [each.value]
type = "A"
zone_id = var.zone_id
ttl = 300
}
Prefer
for_eachovercountfor any resource you might add/remove from the middle of a list — count uses index-based addressing, so removing element 1 of 3 causes element 2 to be destroyed and recreated.
Depends on
resource "aws_s3_bucket_policy" "website" {
bucket = aws_s3_bucket.website.id
policy = data.aws_iam_policy_document.website.json
# Explicit dependency when Terraform can't detect it automatically
depends_on = [aws_s3_bucket_public_access_block.website]
}
Null resource and triggers
resource "null_resource" "deploy" {
triggers = {
image_tag = var.docker_image_tag # re-runs when tag changes
}
provisioner "local-exec" {
command = "kubectl set image deployment/app app=${var.docker_image_tag}"
}
}
Common mistakes
| Mistake | Why it's a problem | Fix |
|---|---|---|
count on dynamic lists |
Removing middle item destroys+recreates trailing resources | Use for_each with a set/map |
Hardcoded credentials in .tf |
Exposed in state and version control | Use provider env vars (AWS_ACCESS_KEY_ID) |
| No remote backend | State stored locally, no team sharing | Add S3 + DynamoDB backend |
terraform apply without plan |
Surprise changes in production | Always plan -out=tfplan first |
No prevent_destroy on databases |
Accidental terraform destroy wipes prod data |
Add lifecycle { prevent_destroy = true } |
| Unpinned provider versions | Provider upgrade breaks configs unexpectedly | Pin with ~> 5.0 (minor-version lock) |
| Secrets in outputs | Shown in CI logs and state | Mark outputs sensitive = true |
Frequently asked questions
What is the difference between terraform plan and terraform apply?plan shows a preview of what would change — no infrastructure is touched. apply executes the plan. Always review a plan before applying, especially in production.
How do I use Terraform with multiple environments (dev/staging/prod)?
Two common approaches: (1) separate directories each with their own backend and terraform.tfvars — safest, most explicit; (2) workspaces within one directory — simpler but shares code, so a config error affects all environments.
What is Terraform state and why does it matter?
Terraform keeps a .tfstate file mapping HCL resources to real infrastructure IDs. If state gets out of sync (e.g., someone deletes a resource manually), Terraform won't know and will try to create a duplicate. Use terraform import to re-sync, and always use remote state for team projects.
How do I handle secrets like database passwords?
Never put secrets in .tf files or .tfvars. Preferred options: (1) read from SSM Parameter Store / Secrets Manager via a data source; (2) pass at apply time with -var or TF_VAR_ environment variables; (3) use Vault provider.
What is the difference between resource and data?resource creates and manages infrastructure. data reads existing infrastructure that Terraform doesn't manage — useful for referencing a VPC or AMI that was created outside Terraform.
How do I upgrade Terraform providers safely?
Run terraform init -upgrade to fetch newer providers matching your version constraints. Then terraform plan to check for breaking changes. Review the provider changelog for major version bumps before widening constraints.