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.

FormExample
Resourceaws_instance.web
count instanceaws_instance.web[0]
for_each instanceaws_s3_bucket.app["logs"]
Data sourcedata.aws_ami.ubuntu
Inside a modulemodule.vpc.aws_subnet.public[1]
Nested modulesmodule.prod.module.vpc.aws_vpc.main
Module instancemodule.region["us-west-2"].aws_vpc.main
Whole modulemodule.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 blockterraform import
Since1.50.x
Visible in planyesno, mutates state immediately
Reviewable in a PRyesno
Bulk friendlyyes, one block per resourceone command per resource
Can generate configyes, -generate-config-outno
Inside a module1.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.tf

Then 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.tfstate before anything that writes. State surgery has no undo, and a wrong state rm orphans real infrastructure.

CommandWhat it does
terraform state list [pattern]list tracked addresses
terraform state show ADDRprint one resource's attributes (-json in 1.16+)
terraform state mv SRC DSTmove or rename in state, prefer a moved block
terraform state rm ADDRforget a resource, prefer a removed block
terraform state pullwrite remote state to stdout
terraform state push FILEoverwrite remote state from a file
terraform state replace-provider FROM TOrewrite provider addresses after a fork or rename
terraform force-unlock LOCK_IDrelease a stuck lock, only when you are sure no run is live
DeprecatedUse instead
terraform taint ADDRterraform apply -replace=ADDR
terraform untaint ADDRremove the -replace, or re-plan
terraform refreshterraform apply -refresh-only
terraform state mva moved block
terraform state rma 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.