Lab 3 · Surgery & Blast Radius
#

The ledger isn’t sacred — it’s a mapping you can surgically edit — Terraform Inside Out
#

David Chan, Claude Opus 4.8 AI-Symbiosis Research · July 2026


In Labs 1 and 2, Terraform created everything and owned the whole ledger. Real life is messier. Infrastructure exists that Terraform never made (someone clicked it in a console). Resources need renaming, rebuilding, careful teardown. This finale is about operating on existing and live state safely — and the key realization, building on Lab 1: the state file is just a mapping between your config addresses and real cloud resources, and you can edit that mapping on purpose. That’s state surgery.

Act 1 — import: adopt infra Terraform didn’t create
#

Start with a bucket created outside Terraform, the way real legacy infra shows up:

$ awslocal s3 mb s3://lab3-preexisting      # made by hand, no Terraform involved

Now write HCL that describes it, but with an empty ledger:

resource "aws_s3_bucket" "adopted" {
  bucket = "lab3-preexisting"
}

plan reveals the problem — using the Lab 1 three-way compare (HCL ⇄ ledger ⇄ cloud):

$ tflocal plan
  # aws_s3_bucket.adopted will be created
Plan: 1 to add, 0 to change, 0 to destroy.

Terraform wants to create it — because the ledger is empty and has no idea the bucket exists. But it does exist: if you ran apply, S3 would reject it with BucketAlreadyExists. The cloud and your HCL agree; the ledger is the one out of sync. So don’t create — adopt. That’s import: it writes the “address ⇄ real resource” mapping into the ledger, touching nothing in the cloud.

$ tflocal import aws_s3_bucket.adopted lab3-preexisting
aws_s3_bucket.adopted: Importing from ID "lab3-preexisting"...
Import successful!

$ tflocal plan
No changes. Your infrastructure matches the configuration.

A bucket born outside Terraform is now fully managed by it — and the cloud was never touched. This is the single most useful real-world Terraform skill: adopting infrastructure that already exists.

Act 2 — state mv: rename without rebuilding
#

Decide adopted is a poor name; rename the resource to main in the HCL. Watch what a mere label change does:

$ tflocal plan
  # aws_s3_bucket.adopted will be destroyed
  # (because aws_s3_bucket.adopted is not in configuration)
  - resource "aws_s3_bucket" "adopted" { ... }

  # aws_s3_bucket.main will be created
  + resource "aws_s3_bucket" "main" { ... }

Plan: 1 to add, 0 to change, 1 to destroy.

Terraform tracks resources by their address in the ledger. You renamed the label, so it concluded "adopted vanished, and this new main must be built." On a real database, that destroy-then-recreate is data gone — from a rename. The physical bucket never changed; only the name we call it by did.

The fix is state mv — relabel the ledger entry to match the new HCL address. Ledger surgery, zero cloud change:

$ tflocal state mv aws_s3_bucket.adopted aws_s3_bucket.main
Successfully moved 1 object(s).

$ tflocal plan
No changes. Your infrastructure matches the configuration.

Renames are free — if you move the ledger with the code. (Modern Terraform also has a declarative moved {} block that does this in config; state mv is the direct, imperative version that shows the mechanism.)

Act 3 — -replace: force a rebuild on purpose
#

Sometimes a resource is fine as far as Terraform knows, but you know it’s wedged — a VM with corrupt state, a container needing a clean rebuild. Force it with -replace (this superseded the old terraform taint):

$ tflocal plan -replace=aws_s3_bucket.main
  # aws_s3_bucket.main will be replaced, as requested
-/+ resource "aws_s3_bucket" "main" { ... }

Plan: 1 to add, 0 to change, 1 to destroy.

Read the symbol, because it distinguishes this from Act 2. Both say “1 add, 1 destroy,” but:

  • Act 2 (rename, no state mv): separate - and + lines on two different addresses — Terraform thinks they’re unrelated resources.
  • Act 3 (-replace): one -/+ line, same address — “destroy and recreate this same resource, as requested.”

Same summary count; completely different meaning. And -replace notes you asked for it — it wasn’t drift.

Act 4 — Blast Radius: the guardrail
#

The scariest command is destroy. How do you stop a careless plan — or yourself at 2am — from nuking production? The prevent_destroy lifecycle guardrail, committed in code:

resource "aws_s3_bucket" "main" {
  bucket = "lab3-preexisting"
  lifecycle {
    prevent_destroy = true
  }
}

Now try to tear it down:

$ tflocal destroy
Plan: 0 to add, 0 to change, 1 to destroy.
...
│ Error: Instance cannot be destroyed
│ Resource aws_s3_bucket.main has lifecycle.prevent_destroy set, but the plan
│ calls for this resource to be destroyed. To avoid this error and continue with
│ the plan, either disable lifecycle.prevent_destroy or reduce the scope of the
│ plan using the -target option.

Terraform planned the destroy and then refused to execute it. That’s a hard stop you put on a production database or your Terraform state bucket, so no plan — however careless — can delete it. And the error hands you the two blast-radius tools:

  1. Disable prevent_destroy — a deliberate, reviewable code edit. You have to mean it.
  2. -target — reduce the scope of the plan. terraform destroy -target=<addr> (or apply -target=<addr>) operates on just that resource, leaving everything else untouched. An incident escape hatch — use it sparingly, but when you need to scope the blast to one thing, it’s exactly right.

Take the honest path — remove the guardrail deliberately, then tear down:

$ tflocal destroy      # type: yes  (lowercase — the confirm is exact-match)
aws_s3_bucket.main: Destroying...
Destroy complete! Resources: 1 destroyed.

Tiny gotcha worth knowing: the destroy confirmation only accepts a literal lowercase yes. Type Yes and Terraform prints Destroy cancelled and does nothing — which, for the most dangerous command, is a fine bias to have.

Lab 3, in one page
#

STATE SURGERY  (the ledger is editable)          BLAST RADIUS  (destroy safely)
─────────────                                    ────────────
import    → adopt existing infra (no cloud change)  prevent_destroy → guardrail; hard-refuses
state mv  → relabel an entry (rename, no rebuild)    -target        → scope an op to one resource
-replace  → force destroy+recreate (-/+)

The series, in one map
#

That closes Terraform Inside Out. Three labs, one idea seen from the inside:

LabThe one idea
1The Ledger & The LoopTerraform is a reconcile loop; the state file is its memory
2Dependencies & Modulesreferences build a DAG; modules are reusable blueprints
3Surgery & Blast Radiusthe ledger is editable; destroy has guardrails

From building one resource, to relating and composing many, to operating on live state without breaking it. The whole series ran on a free local AWS (LocalStack) — every command reproducible on your own machine, no account, no bill. And the through-line never changed: you declare intent, Terraform reconciles reality to match — and the state file is the ledger that makes it all work.