The single most dangerous word in a Terraform plan is destroy. You rename a resource for clarity, wrap a few resources in a module to DRY up three environments, and suddenly terraform plan proposes to tear down a database and replace it. The infrastructure is identical; only the address in state changed. Terraform sees an orphan and a stranger and reconciles them the only way it knows how.
Historically the fix was terraform state mv, terraform import, and terraform state rm run by hand against live state. Those imperative commands are unreviewable, un-replayable, and per-workspace. If you run them once in dev and forget to run them in prod, the divergence surfaces as a destroy in the worst possible place. Since Terraform 1.1 (moved), 1.5 (config-driven import), and 1.7 (removed), all three operations are expressible as configuration: committed to git, reviewed in a PR, applied identically across every workspace, and self-deleting once the refactor lands. This is the playbook I use for restructuring state on running production systems.
1. Why renames and module moves trigger destroy/create
Terraform tracks every managed object by its resource address in state: aws_s3_bucket.logs, or module.network.aws_subnet.private["a"]. The address is the identity. The cloud-side ID (the actual bucket name, the subnet subnet-0abc) lives inside that state entry but is not how Terraform matches config to reality during planning.
When you change the address — rename the resource, move it into a module, add for_each so the key changes — Terraform diffs addresses, not cloud IDs. The old address has config but no state match (it reads as “needs to be created from scratch… but wait, it’s gone”), and the old state entry has no config match (it reads as “managed object no longer in config: destroy it”). The result is the dreaded pair:
# aws_s3_bucket.application_logs will be created
+ resource "aws_s3_bucket" "application_logs" { ... }
# aws_s3_bucket.logs will be destroyed
- resource "aws_s3_bucket" "logs" { ... }
Same bucket. Different label. The job of moved, import, and removed is to teach Terraform that an address changed identity without the underlying object changing, so the plan collapses to a no-op (or a benign in-place update).
Rule of thumb: if a plan shows a paired create + destroy of the same kind of resource and you only edited names or module structure, you have a refactor problem, not an infrastructure problem. Stop. Do not apply. Reach for a
movedblock.
2. Renaming and re-homing resources with moved blocks
A moved block is a directive that says “the object previously at address A is now at address B; carry its state forward.” It runs during plan, before Terraform computes any create/destroy, so the diff never materializes.
The simplest case is a rename:
# Old: resource "aws_s3_bucket" "logs"
# New name for clarity:
resource "aws_s3_bucket" "application_logs" {
bucket = "acme-app-logs-prod"
}
moved {
from = aws_s3_bucket.logs
to = aws_s3_bucket.application_logs
}
Moving resources into a module is the high-value case. Say you have three flat copies of a VPC and you finally extract a network module:
module "network" {
source = "./modules/network"
cidr = "10.20.0.0/16"
}
moved {
from = aws_vpc.main
to = module.network.aws_vpc.this
}
moved {
from = aws_subnet.private["a"]
to = module.network.aws_subnet.private["a"]
}
moved blocks are declarative and idempotent. Once the move has been applied in a workspace, the block is a no-op there forever; running it again does nothing. That is exactly why it is safe to commit one block and let it ripple across dev, staging, and prod on their normal apply cadence — each workspace processes the move the first time it plans and ignores it thereafter. Chaining is allowed (A -> B, then later B -> C), and Terraform resolves the chain, so you can leave history in place across several refactors.
A few hard constraints worth internalizing:
- You can only move within the same configuration / state.
moveddoes not pull objects across separate root modules or backends — that is animport/removedjob (Step 7). - The resource type must stay the same.
movedrenames addresses; it does not convert anaws_instanceinto something else. - Keep
movedblocks at the root of the configuration that owns the destination, not buried inside the child module, when you are pulling resources up or down across the module boundary you control.
After the refactor has shipped everywhere, the blocks are dead weight. You can delete them in a later release — removing a satisfied moved block produces no plan change.
3. Config-driven import with -generate-config-out
moved handles objects Terraform already manages. Import is for objects that exist in the cloud but not in state — a resource someone created in the console, or one you are adopting from another tool. Before 1.5 this meant terraform import ADDR ID (imperative, one resource at a time, and you still had to hand-write the HCL). The import block makes it declarative and plannable, and -generate-config-out writes the HCL for you.
Add an import block pointing at the target address and the cloud ID:
import {
to = aws_iam_role.ci_deployer
id = "ci-deployer"
}
Now generate configuration. The flag must point at a file that does not yet exist — Terraform refuses to overwrite:
terraform plan -generate-config-out=generated.tf
Terraform reads the live resource, emits matching HCL into generated.tf, and shows you a plan that imports rather than creates:
# aws_iam_role.ci_deployer will be imported
resource "aws_iam_role" "ci_deployer" {
name = "ci-deployer"
...
}
Plan: 1 to import, 0 to add, 0 to change, 0 to destroy.
Generated config is a starting point, not a finished artifact — HashiCorp documents this explicitly, and the format may change between minor versions. Treat it as a first draft:
- Resolve conflicting arguments by hand. The generator can emit mutually exclusive attributes (the canonical example is
ipv6_address_countvsipv6_addressesonaws_instance). The plan will error until you delete one. - Replace hard-coded values with references. Generated config inlines literals where you almost certainly want
var.*,module.*, or data-source references. - Sensitive attributes are not populated. You must fill secrets and other sensitive values yourself.
Move the cleaned-up resource out of generated.tf into its proper file, then run terraform apply. Applying an import block writes the object into state and reconciles it against config. Leave the import block in place until you have applied in every workspace, then delete it. Like moved, a satisfied import block is a no-op — Terraform notices the object is already in state and skips it — so a stale block is harmless but clutters the config.
4. Bulk-importing with for_each and import blocks
Importing twenty manually-created log groups one block at a time is tedious. import blocks accept for_each, so you can drive imports from a map and import a whole fleet in one apply.
locals {
legacy_log_groups = {
"api" = "/acme/api"
"worker" = "/acme/worker"
"scheduler" = "/acme/scheduler"
}
}
resource "aws_cloudwatch_log_group" "this" {
for_each = local.legacy_log_groups
name = each.value
retention_in_days = 30
}
import {
for_each = local.legacy_log_groups
to = aws_cloudwatch_log_group.this[each.key]
id = each.value
}
The to argument uses the instance key so each map entry maps to one resource instance; id is the import identifier for that specific object.
The critical limitation: -generate-config-out does not work with for_each (or count) on import blocks or on the target resource. Terraform errors with “the given import block is not compatible with config generation.” The practical workflow is therefore two-phase:
- Write the resource HCL by hand (or generate it for a single representative resource without
for_each, then refactor tofor_each). - Add the
for_eachimportblock and runterraform planto confirm the import map is correct before applying.
Always review the plan output line by line — confirm it shows N to import, 0 to destroy. A typo in an id produces an import failure (loud, safe); a typo in a to key can leave a real resource unmanaged while a planned resource gets created (quiet, dangerous).
5. Decommissioning from state with removed blocks
The mirror of import is removed: take an object out of Terraform’s management. The old way was terraform state rm ADDR — imperative, unreviewable, and it always orphaned the object whether you meant to or not. The removed block (Terraform 1.7+) makes this a reviewed config change with an explicit choice about the object’s fate.
Delete the resource block from your configuration, then add a removed block for the same address:
removed {
from = aws_s3_bucket.legacy_artifacts
lifecycle {
destroy = false
}
}
The nested lifecycle block is required and the destroy argument is the entire point of the feature:
destroy |
Effect |
|---|---|
false |
Forget the object: drop it from state, leave the real infrastructure running. The state-rm equivalent — for handing a resource to another team or tool. |
true |
Remove from state and destroy the real infrastructure on apply. |
The from address must not include instance keys or indices — you reference the resource as a whole (aws_s3_bucket.legacy_artifacts), not aws_s3_bucket.legacy_artifacts["x"]. A removed block can also reference an entire module, which removes every resource that module managed:
removed {
from = module.legacy_cache
lifecycle {
destroy = false
}
}
If you need cleanup to run on the way out — deregistering from an external system, for example — removed blocks support destroy-time provisioners only. Every provisioner must set when = destroy:
removed {
from = aws_instance.bastion
lifecycle {
destroy = true
}
provisioner "local-exec" {
when = destroy
command = "echo 'decommissioned ${self.id}' >> /var/log/teardown.log"
}
}
As with moved and import, once every workspace has processed the removal you delete the removed block. Until then it is idempotent — a workspace that has already forgotten the object treats the block as a no-op.
6. Refactoring across module boundaries and provider aliases
Real refactors cross boundaries. Two cases deserve specific handling.
Promoting resources between parent and child modules. When you pull a resource out of a child module up into the root (or push one down), the moved block lives in the configuration that contains the destination address and references the full source path:
# Pulling a security group out of the network module up to root
moved {
from = module.network.aws_security_group.shared
to = aws_security_group.shared
}
Because moved operates on addresses within one state, both endpoints must resolve in the same root configuration. If the resource is genuinely leaving this state for another root module, that is not a moved — you removed { destroy = false } it here and import it there (Step 7).
Provider aliases. A resource’s state entry records which provider configuration manages it. If you are re-homing a resource that targets an aliased provider (a different region or account), the destination resource must carry the matching provider argument, and the moved block handles the address change as usual:
provider "aws" {
alias = "eu"
region = "eu-west-1"
}
resource "aws_kms_key" "eu_data" {
provider = aws.eu
}
moved {
from = aws_kms_key.data_eu
to = aws_kms_key.eu_data
}
moved does not change the provider association — it carries the existing state forward. If you are actually migrating an object to a different provider/account, that is a destroy-and-recreate or a cross-state removed/import, not a rename.
7. Validating with plan and CI guardrails
Every block above is validated the same way: read the plan and trust nothing else. The non-negotiable check before any apply is that the plan for a pure refactor shows zero destroys:
terraform plan -out=refactor.tfplan
terraform show -json refactor.tfplan > plan.json
Then assert on the machine-readable plan instead of eyeballing colored text. This jq filter prints every address Terraform intends to delete:
jq -r '
.resource_changes[]
| select(.change.actions | index("delete"))
| .address
' plan.json
For a clean state refactor that list must be empty. Wire that into CI as a hard gate so an accidental destroy can never reach apply:
# .github/workflows/terraform.yml (excerpt)
- name: Terraform Plan
run: |
terraform plan -out=tf.plan
terraform show -json tf.plan > tf.plan.json
- name: Block accidental deletes
run: |
DELETES=$(jq -r '
[ .resource_changes[]
| select(.change.actions | index("delete"))
| .address ] | length
' tf.plan.json)
echo "Resources marked for deletion: $DELETES"
if [ "$DELETES" -ne 0 ]; then
echo "::error::Plan deletes $DELETES resource(s). Refactor PRs must be zero-destroy."
jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' tf.plan.json
exit 1
fi
When a genuine teardown is intended (removed { destroy = true }), gate it behind an explicit label or variable so the destroy is a deliberate, reviewed exception rather than the default-allowed case. A belt-and-braces complement is prevent_destroy on irreplaceable resources, which causes the plan itself to error if anything proposes their deletion:
resource "aws_db_instance" "primary" {
# ...
lifecycle {
prevent_destroy = true
}
}
Treat prevent_destroy as a safety interlock, not a workflow — it stops the plan hard, which is the point.
Verify
Walk this checklist on a real refactor before you apply to production:
- Lock and target a single workspace first. Run the refactor against
devend-to-end before touchingprod. Because the blocks are committed config, the higher environments will replay the exact same moves. - Confirm zero destroys on a pure refactor. Run the
jqdelete filter from Step 7 against the JSON plan. Empty output is the pass condition. - Confirm the import count matches your intent. For
importblocks, the plan summary should readN to importwith0 to addfor the resources you are adopting —0 to addproves Terraform matched existing objects rather than planning new ones. - Apply, then re-plan to a clean no-op. The post-apply plan should be empty (or show only intentional in-place updates). A lingering create/destroy means a
moved/importaddress did not match. - Verify state, not just plan. Run
terraform state listand confirm the new addresses exist and the old ones are gone. Spot-check one object withterraform state show ADDRto confirm the cloud ID carried over intact. - Garbage-collect the blocks. After every workspace has applied, delete the satisfied
moved/import/removedblocks in a follow-up PR and confirm that PR also plans to zero changes.
Rollback and recovering from a botched refactor
State refactors are reversible if — and only if — you have state backups. Before any state-mutating apply, snapshot state:
terraform state pull > backup-$(date +%Y%m%d-%H%M%S).tfstate
If you use an S3 backend, enable bucket versioning and DynamoDB locking so every state write is recoverable and concurrent applies are impossible. With a backup in hand, recovery paths are:
- A
movedwent the wrong way. Add a reversingmoved { from = B, to = A }block, or revert the original block before the move is applied. Because moves are idempotent, the reverse is just another reviewed config change. - An
importadopted the wrong object. Add aremoved { from = <that address>, lifecycle { destroy = false } }to forget it again —destroy = falseguarantees the real resource is untouched while you fix theid. - A bad apply corrupted state. Restore the snapshot you pulled:
terraform state push backup-<timestamp>.tfstate(or restore the prior object version from your versioned backend), then re-plan to confirm you are back to a known-good baseline before retrying.
The meta-rule: never run a state-mutating apply you cannot undo. Backup first, prove zero-destroy in CI, apply to the lowest environment, verify, then promote. The blocks are designed so that the same committed change produces the same safe outcome in every workspace — which is the entire reason to prefer them over imperative state mv / import / state rm.