Terraform in Production · Changes That Don't Destroy Things

Refactoring Without Recreating Everything

Renaming a resource or moving it into a module changes its address. moved, removed and import blocks keep real infrastructure untouched.

Scenario

Example team: the payments configuration has grown to 900 lines in one file. An engineer renames aws_s3_bucket.b to aws_s3_bucket.invoices, moves the networking resources into a network module, and deletes a legacy bucket that another team now manages.

What you see in production

The plan says "Plan: 41 to add, 0 to change, 41 to destroy" for what was meant to be a no-op cleanup. In a hurried review it looks like noise from the refactor; applied, it would delete the VPC, every subnet and the invoices bucket, and recreate them empty.

The naive code

# before
resource "aws_s3_bucket" "b" {
  bucket = "acme-invoices-prod"
}

# after: renamed, and the legacy bucket's block simply deleted
resource "aws_s3_bucket" "invoices" {
  bucket = "acme-invoices-prod"
}

Reproduce it

Terraform tracks resources by address. A new address with no history looks like a new resource, and the old address looks deleted:

Rename without a moved block
resource "terraform_data" "b" {
  input = "acme-invoices-prod"
}
# after: rename the resource
resource "terraform_data" "invoices" {
  input = "acme-invoices-prod"
}

Output

Terraform will perform the following actions:

  # terraform_data.b will be destroyed
  # (because terraform_data.b is not in configuration)
  - resource "terraform_data" "b" {
      - id     = "8d396cfd-27c0-c7eb-53ec-bb99498c2b27" -> null
      - input  = "acme-invoices-prod" -> null
      - output = "acme-invoices-prod" -> null
    }

  # terraform_data.invoices will be created
  + resource "terraform_data" "invoices" {
      + id     = (known after apply)
      + input  = "acme-invoices-prod"
      + output = (known after apply)
    }

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

Run this on your computer — see “Get Started” for setup.

Root cause

Configuration addresses (aws_s3_bucket.b, module.network.aws_subnet.private["a"]) are the keys that connect your code to objects in state. Renaming, moving into a module, or deleting a block changes or removes the key, so Terraform's only reading is "destroy the old object, create a new one", or "destroy it" when the block is gone.

The fix

Declare the refactor in code, so it is reviewed, repeatable in every environment and needs no manual terraform state mv:

Rename with a moved block
resource "terraform_data" "b" {
  input = "acme-invoices-prod"
}
# after: rename, and record the move
resource "terraform_data" "invoices" {
  input = "acme-invoices-prod"
}

moved {
  from = terraform_data.b
  to   = terraform_data.invoices
}

Output

Terraform will perform the following actions:

  # terraform_data.b has moved to terraform_data.invoices
    resource "terraform_data" "invoices" {
        id     = "26c5fe85-12c4-2506-42c3-a43ddc8454e6"
        # (2 unchanged attributes hidden)
    }

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

Run this on your computer — see “Get Started” for setup.

Stop managing something without destroying it with a removed block (Terraform 1.7+):

Hand a resource over without deleting it
resource "terraform_data" "legacy_bucket" {
  input = "acme-legacy-exports"
}
# after: another team manages it now
removed {
  from = terraform_data.legacy_bucket
  lifecycle {
    destroy = false
  }
}

Output

Terraform will perform the following actions:

 # terraform_data.legacy_bucket will no longer be managed by Terraform, but will not be destroyed
 # (destroy = false is set in the configuration)
 . resource "terraform_data" "legacy_bucket" {
        id     = "83300e9d-391d-c082-1546-740fa08adf49"
        # (2 unchanged attributes hidden)
    }

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

Run this on your computer — see “Get Started” for setup.

And bring an existing resource under management with an import block (Terraform 1.5+):

import {
  to = aws_s3_bucket.invoices
  id = "acme-invoices-prod"
}
# terraform plan -generate-config-out=generated.tf   writes a starting resource block for you

Moving resources into a module is the same moved block with module addresses: from = aws_subnet.private and to = module.network.aws_subnet.private.

Trade-offs

  • Keep moved blocks for a while (until every environment and every consumer of a shared module has applied them), then delete them.
  • terraform state mv does the same thing imperatively, but it isn't reviewed, has to be repeated in each environment and is easy to get wrong in CI.
  • removed with destroy = false leaves the object running and unmanaged: tell its new owner to import it.

Detect it in production

  • A refactor PR should plan to 0 to add, 0 to change, 0 to destroy, with only "has moved to" lines. Make that the review rule.
  • Fail CI on any delete in a PR labelled refactor.

Key points

  • Addresses connect code to state; changing an address without telling Terraform means destroy-and-create.
  • moved for renames and module moves, removed (with destroy = false) to stop managing, import to adopt.
  • A refactor's plan should be all moves and no changes.

Practice questions

Why does renaming a resource block plan a destroy and a create?

Answer

Terraform links state to configuration by address. The new address has no state, so it's planned as a create; the old address has state but no configuration, so it's planned as a destroy.

How do you stop managing a resource with Terraform without deleting it?

Answer

Replace its block with a removed block whose lifecycle { destroy = false }; the plan shows it 'will no longer be managed by Terraform' and the object keeps running.

Why prefer `moved` blocks over `terraform state mv`?

Answer

Moved blocks live in code: they're reviewed in the pull request, applied consistently in every environment and workspace, and work for module consumers. state mv is a manual, per-state operation outside review.

Open in the app to mark lessons complete, take notes and track your progress.