Real estates are never greenfield. Something was built by hand during an incident, something else predates the team, and something you own needs to move to a different project without going offline. State surgery is how you handle all three.
Topic 1: Import
terraform import adopts an existing resource into state. Two steps, in this order:
# 1. Write the resource block FIRST — import populates state, not configuration
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
tags = { Name = "main" }
}
# 2. Import by real-world ID
terraform import aws_vpc.main vpc-EXAMPLE1234
terraform plan # should report: No changes. Infrastructure is up-to-date.
The general form is terraform import <type>.<local_name> <id>. The ID format differs per resource type — consult the provider documentation, because it is rarely just the obvious identifier.
Import writes state, never configuration. If your block does not match reality, plan proposes changing the live resource to match your code. The iteration loop is: import, plan, read the diff, adjust the configuration to match reality, repeat until clean. Adjusting reality to match a half-written block is how you break the thing you just adopted.
Not every resource supports import — the provider author must implement it, though most resources in mature providers do.
Topic 2: Moving a Resource Between Projects
Deleting a resource from one project and adding it to another physically destroys and recreates it. For a database that is unacceptable. Two safe routes.
Route A — remove, then import:
# In the source project
terraform state rm aws_vpc.main # "Removed aws_vpc.main" — still exists in the cloud
# delete the block from source config, apply (reports nothing to do)
# In the destination project, after writing the block
terraform import aws_vpc.main vpc-EXAMPLE1234
terraform plan # must report no changes
terraform state rm stops Terraform managing a resource. It does not delete anything. That is the whole point — it is how you hand ownership over.
Route B — move state directly:
terraform state mv \
-state-out=../project-a/terraform.tfstate \
aws_vpc.main aws_vpc.primary
The semantics differ meaningfully. Import queries the provider API and rebuilds the state entry from the live resource. state mv copies the existing entry verbatim. The advantage of state mv is that it works even for resources whose provider never implemented import.
Renaming within a project:
terraform state mv aws_vpc.old aws_vpc.new
This is the command that makes refactoring safe. Renaming a resource block without it means Terraform sees the old address disappear and a new one appear — destroy and create. With it, the plan is clean.
Topic 3: The State Command Reference
| Command | Effect |
|---|---|
terraform state list | Every resource address in state |
terraform state show <addr> | All attributes of one resource |
terraform state mv <src> <dst> | Rename or relocate — no destroy/create |
terraform state rm <addr> | Stop managing; does not delete |
terraform state pull | Download and print remote state |
terraform state push | Upload a local state file to the backend |
terraform force-unlock <ID> | Clear a stale lock |
terraform import <addr> <id> | Adopt an existing resource |
state push is the most dangerous command in the list. It overwrites remote state with whatever you hand it. There is a legitimate use — restoring a known-good backup — and a great many illegitimate ones.
Topic 4: Drift
Drift is reality diverging from state because something changed outside Terraform.
terraform plan # ordinary plan surfaces drift as proposed changes
terraform plan -refresh-only # detect drift WITHOUT proposing config changes
-refresh-only is the one to schedule. It answers “has anything changed underneath us?” without mixing that signal in with the changes you intended to make.
Three valid responses, and the choice is a judgement call:
1. Reconcile toward code. Apply the plan; Terraform overwrites the out-of-band change. Correct when someone bypassed the process.
2. Reconcile toward reality. Update the configuration to match what is deployed, then apply — producing a no-op. Correct when the manual change was right and should be kept.
3. Accept it permanently. Add the attribute to ignore_changes when another system legitimately owns it — an autoscaler managing a replica count, a platform injecting tags.
Applying blindly over drift will silently revert someone’s emergency fix. Drift is information: somebody changed something outside your pipeline, and the interesting question is why. A broken emergency process, a missing permission boundary, or a system that genuinely owns that field — each needs a different answer, and only one of them is “apply”.
Automated drift detection on a schedule, alerting on non-empty -refresh-only output, is the mature version of this.
Topic 5: There Is No Rollback
Terraform has no rollback command. This surprises people, and the workarounds are ranked:
- Revert the code and re-apply. The version-controlled configuration is the rollback mechanism. This is the answer in almost every case.
- Restore a state version from backend versioning or
terraform.tfstate.backup— but only when state itself is corrupted, not when infrastructure is wrong. - Correct forward. Edit configuration, plan, apply a fix.
Note the distinction that published material often blurs: there is no rollback command, but backend versioning can restore a prior state file. Those are different operations solving different problems. Restoring old state does not revert infrastructure — it changes Terraform’s beliefs about infrastructure, which without a matching code revert makes the next plan worse, not better.
Topic 6: Replacing a Single Resource
When one resource is in a bad state but the configuration is correct:
terraform apply -replace=aws_instance.web # modern
terraform taint aws_instance.web # legacy equivalent
terraform untaint aws_instance.web
-replace supersedes taint and is clearer: it is a plan-time flag rather than a persistent state mutation, so you see exactly what will happen before committing. Older material still teaches taint; prefer -replace.
Try it yourself: Import a hand-made resource and deliberately leave one attribute wrong in your block. Read the plan. That diff is Terraform telling you your code does not describe reality — resolve it by fixing the code, and watch the plan go clean.
Common mistake: Running terraform state rm expecting it to delete the resource. It does the opposite of destroy — it abandons the resource while leaving it running, which is how orphaned infrastructure accumulates and how surprise invoices happen.