Cheatsheet
Terraform and OpenTofu commands you look up every week
One reference for both engines. The HCL block shapes, the variable and type system, the state surgery commands nobody memorizes, workspaces, modules, and the plan and apply flags worth knowing. Every command below works unchanged whether you type terraform or tofu, and the places where the two genuinely diverge get their own callout.
HCL block types and what each one is for
HCL has about a dozen top-level block types. Learn these and the rest of the language is arguments and expressions.
# Engine and provider requirements. Pin these or your CI is not reproducible.
terraform {
required_version = ">= 1.10.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
backend "s3" {
bucket = "acme-tfstate"
key = "prod/network.tfstate"
region = "us-east-1"
use_lockfile = true
}
}
variable "environment" {
type = string
description = "Deployment environment name."
validation {
condition = contains(["dev", "stage", "prod"], var.environment)
error_message = "environment must be dev, stage, or prod."
}
}
locals {
name_prefix = "acme-${var.environment}"
common_tags = { Environment = var.environment, ManagedBy = "terraform" }
}
data "aws_ami" "base" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "app" {
for_each = toset(["a", "b"])
ami = data.aws_ami.base.id
instance_type = "t3.small"
tags = merge(local.common_tags, { Name = "${local.name_prefix}-${each.key}" })
lifecycle {
create_before_destroy = true
ignore_changes = [tags["LastScanned"]]
}
}
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 6.0"
name = local.name_prefix
cidr = "10.0.0.0/16"
}
output "instance_ids" {
value = [for i in aws_instance.app : i.id]
description = "IDs of every application instance."
} | Block | What it declares |
|---|---|
| terraform { } | Engine version, required providers, and the state backend. One per configuration. |
| provider "aws" { } | Credentials, region, and default tags. Add an alias to configure a second region. |
| resource "type" "name" { } | Something the engine creates, updates, and destroys. The core building block. |
| data "type" "name" { } | Read-only lookup of something that already exists. Refreshed on every plan. |
| variable "name" { } | An input with a type, an optional default, and optional validation rules. |
| output "name" { } | A value exported from this configuration or module. Mark secrets sensitive. |
| locals { } | Named intermediate expressions. Not inputs, not outputs, just readability. |
| module "name" { } | Instantiates a reusable configuration from a local path, Git URL, or registry. |
| moved { } | Records a rename so a refactor does not destroy and recreate the resource. |
| import { } | Declarative adoption of existing infrastructure, visible in the plan first. |
| removed { } | Drops a resource from state without destroying the real thing. |
| check "name" { } | Post-apply assertions that warn instead of failing the run. |
| lifecycle { } | Nested in a resource: create_before_destroy, prevent_destroy, ignore_changes, replace_triggered_by. |
| for_each / count | Meta-arguments that multiply a resource or module. Prefer for_each with stable keys. |
| depends_on = [ ] | Explicit ordering for dependencies the engine cannot infer from references. |
Gotcha: count indexes resources by position, so deleting the first element renumbers everything after it and the plan proposes destroying resources you never touched. for_each keys by a stable string instead, which is why it is the right default for anything beyond a simple on/off toggle.
Variables, types, and validation
Typed variables catch a class of mistakes at plan time rather than halfway through an apply, when half the infrastructure already exists.
| Declaration | Meaning |
|---|---|
| type = string | Primitive. The others are number and bool. |
| type = list(string) | Ordered collection. Duplicates allowed, position is meaningful. |
| type = set(string) | Unordered, unique. What for_each actually wants. |
| type = map(number) | String keys to values of one uniform type. |
| type = object({ a = string }) | Named attributes with individual types. The right shape for module inputs. |
| optional(string, "us-east-1") | Optional object attribute with a per-attribute default. |
| type = any | Escape hatch. Convenient, and it turns type errors into runtime errors. |
| sensitive = true | Redacts the value from plan and apply output. It is still in plain state. |
| nullable = false | Reject an explicit null so the default is always used instead. |
| ephemeral = true | Value exists only during the run and is never written to state. For short-lived tokens. |
| validation { condition ... } | Custom rule with an error message, evaluated before any plan is produced. |
| -var 'region=us-east-1' | Set one variable on the command line. |
| -var-file=prod.tfvars | Load a whole file of values. Repeatable, later files win. |
| export TF_VAR_region=us-east-1 | Environment variable form. OpenTofu reads the same TF_VAR_ prefix. |
| terraform.tfvars | Loaded automatically, as is any file matching *.auto.tfvars. |
Gotcha: precedence runs environment variables, then terraform.tfvars, then *.auto.tfvars in lexical order, then explicit -var-file and -var flags in the order given, with the last one winning. When a value is mysteriously not what you set, run terraform console and print it.
Core CLI: init, plan, apply, destroy
Swap in tofu for terraform and every one of these works identically.
| Command | What it does |
|---|---|
| terraform init | Download providers and modules, configure the backend, write the lock file. |
| terraform init -upgrade | Re-resolve providers within their constraints and update the lock file. |
| terraform init -backend-config=prod.hcl | Supply backend settings from a file rather than hardcoding them. |
| terraform init -migrate-state | Move existing state after changing the backend block. |
| terraform init -reconfigure | Reinitialize the backend and ignore the previously saved configuration. |
| terraform plan | Refresh, compare, and print the change set. Never mutates infrastructure. |
| terraform plan -out=tf.plan | Save the plan so apply executes exactly what was reviewed. Do this in CI. |
| terraform plan -detailed-exitcode | Exit 0 for no change, 2 for changes, 1 for error. Drift detection in one line. |
| terraform plan -target=module.vpc | Scope to one address. An incident tool, not a workflow. |
| terraform plan -refresh=false | Skip provider reads. Much faster on huge state, and it can hide real drift. |
| terraform apply tf.plan | Apply a saved plan. No prompt, because the decision was already made. |
| terraform apply -auto-approve | Skip the confirmation prompt. Only safe with a reviewed saved plan upstream. |
| terraform apply -parallelism=5 | Cap concurrent operations. Lower it when a provider API rate-limits you. |
| terraform apply -replace=aws_instance.app | Force one resource to be destroyed and recreated. Replaces the old taint command. |
| terraform destroy | Plan and execute the removal of everything in this state. |
| terraform plan -destroy | Preview a destroy without any chance of running it. Read this before you type destroy. |
| terraform apply -lock-timeout=5m | Wait for a held state lock instead of failing immediately. |
| terraform output -json | Machine-readable outputs. Pipe into jq to feed the next pipeline step. |
| terraform show -json tf.plan | Render a saved plan as JSON for policy checks or a pull request comment. |
| terraform console | Interactive REPL against the current state. The fastest way to debug an expression. |
Gotcha: a plan is only valid against the state it was generated from. If someone else applies between your plan and your apply, the saved plan is rejected rather than silently applied to a moved target. That rejection is the feature; do not work around it by re-planning with -auto-approve in the same job.
State commands and import
State is a database, and these are its surgical instruments. Back it up before every one of them.
| Command | What it does |
|---|---|
| terraform state list | Every resource address currently tracked. Add a pattern to filter. |
| terraform state show aws_instance.app | Full recorded attributes of one resource, as the engine sees it. |
| terraform state mv A B | Rename an address without touching the real resource. Prefer a moved block. |
| terraform state rm aws_instance.app | Stop managing a resource. It keeps running; the engine simply forgets it. |
| terraform state pull > backup.tfstate | Download remote state to a local file. Do this before any surgery. |
| terraform state push backup.tfstate | Overwrite remote state from a local file. Dangerous and occasionally essential. |
| terraform state replace-provider A B | Repoint every resource at a different provider source address. |
| terraform import aws_instance.app i-0abc | Adopt an existing resource into state. The config must already describe it. |
| terraform plan -generate-config-out=gen.tf | With import blocks, writes draft HCL for the resources being adopted. |
| terraform force-unlock LOCK_ID | Clear a stale lock left by a crashed run. Confirm nothing is still running. |
| terraform refresh | Deprecated in favour of apply -refresh-only, which shows you the diff first. |
| terraform apply -refresh-only | Reconcile state with reality and approve the drift explicitly. |
| terraform providers | Provider requirements per module, including inherited ones. |
| terraform providers lock -platform=linux_amd64 | Add hashes for another platform so CI and laptops share one lock file. |
Gotcha: state files hold every attribute of every resource in plain text, including database passwords and generated private keys, whether or not the variable was marked sensitive. Never commit state to Git, always enable backend encryption, and restrict read access on the bucket the same way you would on the database itself.
Workspaces, modules, and the registry
Workspaces
| Command | What it does |
|---|---|
| workspace list | All workspaces, current one starred. |
| workspace new staging | Create one and switch to it. Starts with empty state. |
| workspace select prod | Switch. Add -or-create to make it idempotent in CI. |
| workspace show | Print the current workspace name only. |
| workspace delete staging | Remove it. Refuses while its state is non-empty. |
| terraform.workspace | Expression giving the active name inside HCL. |
Gotcha: workspaces share one configuration and one backend, so they suit ephemeral copies of the same thing far better than they suit prod versus dev. For genuinely different environments, separate directories with separate state keys are easier to reason about and much harder to blow up by forgetting which workspace you are in.
Modules and formatting
| Command | What it does |
|---|---|
| get -update | Re-fetch modules without touching providers. |
| fmt -recursive | Canonical formatting across every subdirectory. |
| fmt -check -diff | Non-zero exit if anything is unformatted. The CI form. |
| validate | Syntax and internal consistency. No provider calls, no credentials needed. |
| test | Run .tftest.hcl files against real or mocked providers. |
| graph | dot -Tsvg | Render the dependency graph to an image. |
| version | Engine and provider versions in use right now. |
Module sources accept a registry address (namespace/name/provider), a Git URL with ?ref=v1.2.0, a local ./path, or an S3 or GCS object. Always pin a version or a ref: an unpinned module is a supply chain that can change under you between two plans.
Where OpenTofu diverges from Terraform
Read this before you assume they are identical
The CLI surface is still compatible enough that alias terraform=tofu works for most configurations, and both read the same HCL and the same state format. The feature sets stopped being identical some time ago. The headline OpenTofu-only capability is client-side state encryption: you declare an encryption block with an AWS KMS, GCP KMS, or PBKDF2 key provider, and the state file is encrypted before it ever reaches the backend. Terraform has no equivalent and relies entirely on the storage layer for encryption at rest.
OpenTofu-only, as of mid-2026
- Native state and plan encryption with pluggable key providers.
- Early variable evaluation, so a variable can be used in a module source or a backend block.
- for_each on provider blocks, which removes a lot of copy-pasted multi-region boilerplate.
- OCI registries as a distribution channel for providers and modules.
- Dynamic prevent_destroy, so the guard can be driven by a variable.
- MPL 2.0 licence under Linux Foundation governance rather than the Business Source Licence.
Terraform-only, as of mid-2026
- List resources and the query command for bulk discovery of unmanaged infrastructure.
- Action blocks, which invoke provider-defined operations on a lifecycle trigger.
- Deep HCP Terraform integration: remote runs, Sentinel policies, the private module registry, and Stacks.
- First-class support in vendor documentation, which still says "Terraform" by default.
Migrating an existing project is usually a one-line change: install the tofu binary, run tofu init against the same backend, and the state is read as-is. The real work is in CI configuration, any wrapper tooling such as Terragrunt or Atlantis, and policy-as-code that shells out to a named binary. The full argument for each side is in the Terraform vs OpenTofu comparison.
Reference documentation: developer.hashicorp.com/terraform and opentofu.org.
Keep going
For how these tools compare against Pulumi, SST, and Ansible, read the infrastructure as code roundup. To run plan and apply on every pull request, the CI/CD pipeline guide covers the approval gate and the credentials setup.
Related references: the bash and Linux cheatsheet for the glue scripts around a run, the kubectl cheatsheet for what gets deployed onto the infrastructure, and the hosting platform directory for the providers these configurations target.