Terraform Cheatsheet

Basics

Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.

What Terraform Does

Terraform is an infrastructure-as-code tool that provisions and manages cloud resources from declarative config files. You describe what you want in HCL, Terraform diffs that against recorded state, then calls provider APIs to reconcile the difference.

Current stable line is 1.16 (1.16.0 shipped 26 August 2026). The two most recent language additions worth knowing: 1.14 (19 November 2025) added List Resources with .tfquery.hcl files plus the terraform query command, and top-level action blocks. 1.15 (April 2026) added deprecated on variables and outputs, the convert() function, and variables in module source.

Core Workflow

terraform init      # download providers and modules, set up the backend
terraform validate  # syntax and type check, no credentials needed
terraform plan      # diff: what will change?
terraform apply     # make it so
terraform destroy   # tear everything down
# The production loop: review the plan you actually apply
terraform plan -out=plan.tfplan
terraform show plan.tfplan          # or -json for tooling
terraform apply plan.tfplan         # no prompt, no re-plan

terraform apply without a saved plan file re-plans and prompts. In CI, always -out a plan and apply that file, so what ships is what was reviewed.

HCL and Block Types

Every Terraform file is a list of blocks. These are the top-level block types, with the version that introduced each.

Top-Level Block Types

BlockPurposeSince
terraformversion constraints, providers, backend0.x
providerconfigure a plugin0.x
resourcemanaged object Terraform creates0.x
dataread-only lookup0.x
variableinput0.x
outputexported value0.x
localsnamed local expressions0.x
modulecall another configuration0.x
movedrelocate an object in state1.1
importadopt existing infrastructure1.5
checkpost-apply assertion that warns1.5
removeddrop from state without destroying1.7
ephemeralvalue never written to state or plan1.10
actionprovider-defined side effect1.14
listquery existing infrastructure (.tfquery.hcl)1.14

Block Anatomy

resource "aws_instance" "web" {
#  ^type    ^label1     ^label2
  ami           = "ami-0c55b159cbfafe1f0"   # argument
  instance_type = "t3.micro"

  tags = {                                  # object value
    Name = "web-server"
    Env  = "prod"
  }

  root_block_device {                       # nested block
    volume_size = 20
  }
}
  • Block type decides how many labels follow. resource and data take two, variable and output take one, terraform and locals take none.
  • Arguments are name = expression. Nested blocks have no =.
  • Identifiers may hold letters, digits, _ and -, and must not start with a digit.
# Strings and interpolation
name = "server-${var.environment}"

# Heredoc, the - strips leading indentation
user_data = <<-EOT
  #!/bin/bash
  yum install -y httpd
EOT

# Comments
# single line
// also single line
/* multi
   line */

terraform fmt is canonical. Run it and stop arguing about alignment.

Project Structure

project/
├── main.tf            # core resources
├── variables.tf       # input variable declarations
├── outputs.tf         # output value declarations
├── providers.tf       # provider configuration
├── versions.tf        # the terraform block
├── terraform.tfvars   # variable values (never commit secrets)
├── tests/             # *.tftest.hcl
├── .terraform.lock.hcl   # provider lock file, DO commit
└── .terraform/        # downloaded plugins and modules, never commit

Every .tf file in one directory is merged into a single root module. File names carry no meaning to Terraform, only to humans, so the split above is convention.

# .gitignore essentials
.terraform/
*.tfstate
*.tfstate.*
*.tfvars
crash.log

Note the exception: .terraform.lock.hcl is committed. It pins provider versions and checksums for the whole team.

The terraform Block

terraform {
  required_version = ">= 1.10.0, < 2.0.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"     # <namespace>/<type>
      version = "~> 6.0"
    }
    random = {
      source  = "hashicorp/random"
      version = "~> 3.6"
    }
  }

  backend "s3" {
    bucket       = "my-tf-state"
    key          = "prod/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true
  }
}

Rules that catch people out:

  • The terraform block takes no expressions. No var.*, no functions, no interpolation. Everything must be literal.
  • required_version gates the Terraform CLI. required_providers gates plugins. Both are checked at init.
  • A child module should declare required_providers but never a backend. Only the root module has one.
  • Use cloud { ... } instead of backend for HCP Terraform.

Version Constraints

ConstraintAllows
= 1.6.0exactly 1.6.0
!= 1.5.0anything except 1.5.0
>= 1.6.01.6.0 and up
<= 1.9.01.9.0 and down
~> 1.6>= 1.6.0, < 2.0.0 (last component may rise)
~> 1.6.0>= 1.6.0, < 1.7.0
>= 1.4, < 2.0both must hold

Convention: ~> MAJOR.0 for providers you trust to respect semver, ~> MAJOR.MINOR.0 when a minor bump has burned you. Pin exactly only when you have a specific reason, because it blocks security patches.

Locals

locals {
  env         = var.environment
  name_prefix = "myapp-${local.env}"     # locals may reference locals

  common_tags = {
    Project     = "myapp"
    Environment = local.env
    ManagedBy   = "terraform"
  }
}

resource "aws_instance" "web" {
  tags = merge(local.common_tags, { Name = "${local.name_prefix}-web" })
}

Locals are computed once per plan and cannot be set from outside. Use a variable for anything a caller should override, and a local for anything derived.

Meta-Arguments and Addressing

Every resource has an address used in plan output, state commands and depends_on.

Address formExample
<type>.<name>aws_instance.web
indexedaws_instance.web[0], aws_s3_bucket.app["logs"]
in a modulemodule.vpc.aws_vpc.main
data sourcedata.aws_ami.ubuntu
variable / local / outputvar.x, local.y, output.z
action (1.14+)action.aws_lambda_invoke.warm

The five meta-arguments (count, for_each, depends_on, provider, lifecycle) plus dynamic blocks are covered in depth in the Meta-Arguments and Lifecycle topic.