Terraform Cheatsheet
Import and Refactoring
Use this Terraform reference while you build software engineering projects, review code, or refresh the syntax you reach for most.
Resource Addressing
Every import, move, remove and state command takes an address. Get the syntax right and the rest is mechanical.
| Form | Example |
|---|---|
| Resource | aws_instance.web |
count instance | aws_instance.web[0] |
for_each instance | aws_s3_bucket.app["logs"] |
| Data source | data.aws_ami.ubuntu |
| Inside a module | module.vpc.aws_subnet.public[1] |
| Nested modules | module.prod.module.vpc.aws_vpc.main |
| Module instance | module.region["us-west-2"].aws_vpc.main |
| Whole module | module.vpc |
terraform state list # every address in state terraform state list 'module.vpc.*' # filtered
Quote addresses in the shell. [, ], " and * are all shell metacharacters, and an unquoted aws_s3_bucket.app["logs"] will not reach Terraform intact.
Importing: import Block vs terraform import
Reach for the import block (1.5+). It shows up in plan, it is reviewable in a pull request, and it works in CI. The terraform import command is the legacy imperative path.
# imports.tf import { to = aws_instance.web id = "i-0abcd1234efgh5678" } # With for_each instances, one block per instance import { to = aws_s3_bucket.app["logs"] id = "myapp-logs" }
terraform plan # shows "1 to import", errors if config is missing terraform apply # performs the import, writes state
import block | terraform import | |
|---|---|---|
| Since | 1.5 | 0.x |
| Visible in plan | yes | no, mutates state immediately |
| Reviewable in a PR | yes | no |
| Bulk friendly | yes, one block per resource | one command per resource |
| Can generate config | yes, -generate-config-out | no |
| Inside a module | 1.16+ | n/a |
import blocks are not permanent. Delete them once the apply lands, they are one-shot instructions.
Since 1.12 a provider may support importing by identity instead of a raw ID, using identity = { ... } in place of id. The two are mutually exclusive, and support is per resource type.
Generating Config for an Import
You do not have to hand-write the resource body. Declare the import block, leave the resource block out, and let Terraform scaffold it.
# The file must not already exist
terraform plan -generate-config-out=generated.tfThen review it before applying. Generated config is a starting point, not finished work: it inlines every computed default, keeps no variables, and often needs attributes deleted or replaced with references.
Bulk Discovery with terraform query (1.14+)
List Resources let you find unmanaged infrastructure before importing it. They live in *.tfquery.hcl files, separate from your configuration.
# unmanaged.tfquery.hcl list "aws_instance" "prod" { provider = aws limit = 50 # default 100 config { region = "us-east-2" filter { name = "tag:Name" values = ["prod-*"] } } }
terraform query # print matches terraform query -json # machine-readable terraform query -generate-config-out=to-import/found.tf
The -generate-config-out form writes both import and resource blocks for every match, which is the fast path for adopting a large hand-built estate. list blocks also accept count, for_each, provider and include_resource = true (slower, returns full resource state).
Renaming and Moving: the moved Block
A moved block (1.1+) tells Terraform that an object it already tracks now lives at a different address. No destroy, no recreate.
# Rename in place moved { from = aws_instance.old_name to = aws_instance.new_name } # Absorb a resource into a module moved { from = aws_instance.web to = module.servers.aws_instance.web } # count to for_each moved { from = aws_subnet.public[0] to = aws_subnet.public["us-east-1a"] } # Whole module rename moved { from = module.app to = module.application }
This is the modern replacement for almost every terraform state mv. It is declarative, it appears in plan, and because it ships inside a module it also migrates every consumer of that module. Leave the block in place for at least one release cycle so downstream users get the move too, then delete it.
Constraints: both addresses must be static, the types must match, and from must not still exist in config.
Dropping Without Destroying: the removed Block
A removed block (1.7+) forgets a resource, leaving the real infrastructure alone. It replaces terraform state rm.
# Delete the resource block, then add this removed { from = aws_instance.legacy lifecycle { destroy = false # false forgets it, true destroys it } }
Set destroy = true when you do want the object gone but the config block is already deleted. As with moved, keep the block for a release then remove it. removed also works on whole modules (from = module.old).
State Subcommands and Deprecated Commands
Back state up first.
terraform state pull > backup.tfstatebefore anything that writes. State surgery has no undo, and a wrongstate rmorphans real infrastructure.
| Command | What it does |
|---|---|
terraform state list [pattern] | list tracked addresses |
terraform state show ADDR | print one resource's attributes (-json in 1.16+) |
terraform state mv SRC DST | move or rename in state, prefer a moved block |
terraform state rm ADDR | forget a resource, prefer a removed block |
terraform state pull | write remote state to stdout |
terraform state push FILE | overwrite remote state from a file |
terraform state replace-provider FROM TO | rewrite provider addresses after a fork or rename |
terraform force-unlock LOCK_ID | release a stuck lock, only when you are sure no run is live |
| Deprecated | Use instead |
|---|---|
terraform taint ADDR | terraform apply -replace=ADDR |
terraform untaint ADDR | remove the -replace, or re-plan |
terraform refresh | terraform apply -refresh-only |
terraform state mv | a moved block |
terraform state rm | a removed block |
-replace and -refresh-only win because both show up in a plan you can review, while taint and refresh mutate state behind everyone's back.
terraform apply -replace='aws_instance.web[0]' terraform plan -refresh-only # review detected drift terraform apply -refresh-only # accept it into state
Worked Example: Adopting a Hand-Created Resource
An S3 bucket acme-legacy-assets was created in the console. Bring it under management without a moment of downtime.
# 1. Confirm it is not already tracked
terraform state list | grep -i legacy# 2. Declare the import target with no resource body yet import { to = aws_s3_bucket.assets id = "acme-legacy-assets" }
# 3. Scaffold the resource block from the live object
terraform plan -generate-config-out=generated.tf# 4. Move the useful parts into main.tf by hand, swapping # literals for variables and references resource "aws_s3_bucket" "assets" { bucket = "acme-legacy-assets" tags = local.common_tags }
# 5. Plan again. The goal is "1 to import, 0 to add, 0 to change, # 0 to destroy". Anything else means your config drifted from # reality, so fix the config, never the bucket. terraform plan # 6. Apply, then verify terraform apply terraform state show aws_s3_bucket.assets
# 7. Delete the import block. It has done its job.If step 5 shows a destroy and create, stop. Some attribute you wrote forces replacement. Compare against terraform state show output and fix the config before applying.