Toolmingo
Guides11 min read

Terraform Cheat Sheet: Commands, HCL Syntax & Patterns

A complete Terraform cheat sheet — CLI commands, HCL syntax, variables, modules, state management, workspaces, and production patterns with copy-ready examples.

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_each over count for 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.

Related tools

Keep reading

All Toolmingotools are free & run in your browser

No sign-up, no upload, no watermark. Your files never leave your device.

Browse all tools