IaCTOOL-SPECIFICCLOUD-SPECIFICDATABASE-SPECIFIC

Destructive Changes: What a Rename Really Does

Renaming a resource in configuration is read as delete-then-create, because the tool identifies resources by their address — and on a database that is the end of the data.

The question, the obvious approach, and why it breaks

Every lesson starts where the work starts: an operational problem, a first attempt that is entirely reasonable, and the way production disagrees with it.

The production question

I renamed a resource block. Why does the plan say it will be destroyed?

The problem

The tool identifies a resource by its address in the configuration, not by anything about the resource itself. Change the address and you have, as far as the tool is concerned, deleted one resource and declared a new one.

What teams do first

Rename aws_db_instance.db to aws_db_instance.primary because the old name was unclear. It is a one-word change in a pull request, and the diff looks trivial.

How it breaks

The plan reads 1 to add, 0 to change, 1 to destroy. The source diff was one word; the effect is the deletion of a production database.

How it breaks in production
  • The plan reads 1 to add, 0 to change, 1 to destroy. The source diff was one word; the effect is the deletion of a production database.
  • The default destroy order is destroy-then-create. The old instance is gone before the new one exists, so even the accidental hope of copying something across is unavailable.
  • A reviewer looking at the source diff cannot see any of this. This is precisely why the plan, not the diff, is the artifact under review (The Plan: Desired vs Current).
  • The same shape appears without any rename: moving a resource into a module, reordering a list that drives a count, changing a for_each key, or changing an attribute the provider marks as forcing replacement.
  • On anything holding data the operation is not reversible by any subsequent apply. The rollback is a restore, with whatever data loss the last backup implies (Partial and Logical Data Recovery).
CodeBuildTestArtifactReleaseDeployRunObserveOperateIncidentRecoverLearnImprove

What is actually happening

Underneath the tooling, which is the part that survives a change of tool.

  • The address is the identity. module.data.aws_db_instance.primary is a key in state pointing at a real resource id. The tool compares the set of addresses in configuration with the set in state.
  • An address present in state and absent in configuration means delete. An address present in configuration and absent in state means create. A rename produces both at once, and the tool has no way to know they are the same thing.
  • Replacement is the second path to the same outcome: when a changed attribute is marked as forcing new by the provider, the tool destroys and recreates rather than updating. Which attributes those are is a provider decision that can change across provider versions.
  • Two mechanisms exist to tell the tool that an address moved: a moved block in configuration, which is reviewed and applied like any other change, and a state move command, which is imperative and immediate. The declarative one is better because it is in the pull request.
  • Index-based addressing is the quiet version. With count, addresses are positional — removing the first element of a three-element list renames all three, and the plan shows three replacements for what looked like one deletion. Keyed iteration avoids this by making the address depend on a stable key rather than a position.

The one-word diff and the plan it produces

This is the whole lesson in one screen. The pull request is trivially reviewable; the plan is not, and only one of them is the truth.

Source diff, then plan
1--- a/database.tf
2+++ b/database.tf
3-resource "aws_db_instance" "db" {
4+resource "aws_db_instance" "primary" {
5
6 identifier = "prod-orders"
7
8-- plan --------------------------------------------------------------
9
10 # aws_db_instance.db will be destroyed
11 # (because aws_db_instance.db is not in configuration)
12 - resource "aws_db_instance" "db" {
13 - identifier = "prod-orders"
14 }
15
16 # aws_db_instance.primary will be created
17 + resource "aws_db_instance" "primary" {
18 + identifier = "prod-orders"
19 }
20
21Plan: 1 to add, 0 to change, 1 to destroy.

Nothing in the source diff hints at this. The reviewer approving a one-word rename and the operator approving "1 to destroy" have to be the same person looking at the same artifact, which is the argument for putting the plan in the pull request.

Two guards, at two different layers

CLOUD-SPECIFICShown with one provider's managed relational database. The equivalents exist nearly everywhere — deletion protection or locks on managed databases, termination protection on instances, retention policies and object locks on storage — but they are named differently and cover different operations. Enabling the compute one and assuming it covers the volume is a common gap.

The moved block tells the tool the resource is the same one. The lifecycle guard and the provider-side protection stop it doing damage when something else goes wrong. They are not alternatives — the first prevents this specific mistake, the second two catch the ones you did not anticipate.

The rename done safely, with defence in depth
1# 1. Tell the tool the address moved. Reviewed like any change.
2moved {
3 from = aws_db_instance.db
4 to = aws_db_instance.primary
5}
6
7resource "aws_db_instance" "primary" {
8 identifier = "prod-orders"
9 engine = "postgres"
10 instance_class = "db.m6g.large"
11 allocated_storage = 200
12
13 # 2. Provider-side protection: survives the IaC tool being wrong.
14 deletion_protection = true
15
16 # 3. If it is ever deleted, take a snapshot on the way out.
17 skip_final_snapshot = false
18 final_snapshot_identifier = "prod-orders-final"
19 backup_retention_period = 14
20
21 lifecycle {
22 # 4. The tool refuses to plan a destroy at all.
23 prevent_destroy = true
24 }
25}

Layer 4 fails loudly and is the one someone deletes to make an apply pass. Layers 2 and 3 are the ones that hold when that happens, because removing them is a separate, visible change to the resource itself.

How a Friday afternoon rename becomes a restore

Reconstructed from the shape these incidents actually take. Note that no individual step is unreasonable, and that the destructive operation was visible in writing to two people.

Rename to restore, in twenty-six minutes
  1. 16:52changePull request opened: rename db to primary for clarity, plus three unrelated tag changes
  2. 16:55signalCI posts the plan as a comment. It is 340 lines; the summary line is at the bottom
  3. 16:58actionReviewer reads the source diff, sees a rename and some tags, approves
  4. 17:01changeMerged. Pipeline applies automatically — no destroy gate configured on this state
  5. 17:02changeApply begins with the destroy, because destroy-then-create is the default order
  6. 17:03signalConnection errors across every service that talks to the database
  7. 17:04changeApply continues and creates an empty instance with the same identifier and a new endpoint
  8. 17:06actionPaged. First hypothesis is a network problem, because the deploy that ran was "a tag change"
  9. 17:14actionSomeone reads the apply log rather than the pull request and finds the destroy
  10. 17:18actionRestore started from the most recent automated backup
  11. 18:41recoveryRestore complete. Writes between the last backup and 17:02 are gone; reconciliation begins

Three cheap controls would each have stopped this independently: a destroy gate on the pipeline, prevent_destroy on the resource, and provider-side deletion protection. The expensive control — a careful reviewer — was present and did not help, because the information was not where they were looking.

changesignalactionrecovery

How to do it properly

Most important first.

  • Read the plan for destroys and replacements before reading anything else. The summary line is the first thing to look at, not the last.
  • Use a moved block for every rename or refactor, in the same commit as the rename, so the reviewer sees the intent and the plan shows no destroy.
  • Put prevent_destroy on every resource that holds data. It turns a catastrophic apply into a failed apply, which is the correct outcome.
  • Enable provider-side deletion protection independently — it is the guard that survives the IaC tool being wrong, someone removing the lifecycle block, or a plan being applied from a laptop (Reducing Blast Radius).
  • Prefer keyed iteration over count-based iteration for anything that will grow or shrink, so removing an element does not renumber its neighbours.
  • Gate the pipeline: parse the plan, and require a named human approval whenever destroy or replace is non-zero (Policy as Code, Required Checks).

How much can this affect

Every production change has a blast radius. Stated as a scale so it is comparable between changes rather than adjectival — and paired with what actually contains it, because a wide scope with a real containment mechanism is a different situation from a wide scope with none.

Blast radius if this is wrongEveryone
One testEveryone
What contains it

Provider-side deletion protection plus a restorable, tested backup. The plan gate is the first line and the one most likely to be skipped at 17:45 on a Friday; the provider-side guard is the one that actually holds.

What can go wrong

Failure modes, including of the mitigation
  • A prevent_destroy that someone removes in the same pull request as the rename, because the apply failed and removing the guard made it pass.
  • A moved block written after the destructive apply, which is the correct fix applied at the wrong time.
  • State surgery performed under time pressure to "fix" a plan, which moves the wrong resource and produces two states claiming the same one (State).
  • Deletion protection enabled on the database and not on the volume, the snapshot policy, or the subnet the database lives in — the guard is only as good as its coverage.
  • An automated apply on merge with no destroy gate, which turns a review oversight into an immediate outage with no human in between.
  • Destroying and recreating a resource that other systems reference by id — a security group, a load balancer target — which succeeds and silently breaks everything pointing at the old id.
Misreads this invites
  • "The diff was one word, so the change is small." Source diff size has no relationship to plan size. This lesson exists because those two look unrelated and are treated as if they were the same.
  • "prevent_destroy protects the data." It stops the tool from issuing a delete. It does nothing about a delete issued by a console, another automation, or a provider-side lifecycle rule — which is why provider-side protection is a separate layer.
  • "Replacement is fine, it recreates the resource." It creates an empty resource with the same configuration and a different id. Configuration is not contents.
  • "Terraform is unusually dangerous here." Every declarative tool that identifies resources by a logical name has this behaviour. CloudFormation replaces a resource whose logical id changed; Pulumi does the same without an alias. The mitigations have different names — moved, alias, retain policies — and the mechanism is identical.

Operating it

Evidence is the signal, not the intention. Rollback is sometimes 'you cannot, and that is the point'.

How you know it worked
  • Plans in the pipeline print the destroy and replace counts as a check status, and a non-zero count blocks without an approval.
  • A deliberate test in a non-production account confirms that prevent_destroy and provider-side protection both actually stop the apply.
  • Every rename in the repository history has a matching moved block in the same commit.
How you get back
  • For a stateless resource, re-applying the previous configuration recreates it, and the only cost is the outage between destroy and create.
  • For a stateful resource, there is no rollback. The path is: restore from the most recent backup or snapshot, accept the data loss between that point and the destruction, and then reconcile whatever wrote to the system in between (Restore Drills).
  • If the resource was referenced by id elsewhere — a security group, a target group, an endpoint — the recreated resource has a new id and everything pointing at the old one must be updated too. Expect the second wave of failures a few minutes after the first.
What to automate, and what stays human
  • Automate: plan parsing, destroy and replace counting, policy rejection of destructive operations on a protected class of resource, and detection of a moved block missing from a rename.
  • Keep human: approval of every destroy and replace. The tool cannot know whether a resource holds the only copy of something; a person can (Manual Production Changes).
What this costs
  • prevent_destroy makes legitimate deletion require a code change in two places. That friction is the feature, and it is genuinely annoying when decommissioning something on purpose.
  • Approval gates on every destroy slow down cleanup of ephemeral environments. Put those in a separate state so their destroys do not consume the gate's credibility (Ephemeral Environments).
  • Keyed iteration produces uglier addresses than positional indexes and is worth it the first time someone removes an element from the middle of a list.

Where this applies

This domain is unusually tool- and organisation-dependent. These labels say what each claim is specific to, and what a different platform, provider or organisation does instead.

  • TOOL-SPECIFICTerraform/OpenTofu key state by configuration address and offer moved blocks and terraform state mv. CloudFormation keys by logical id and replaces on a logical id change, with DeletionPolicy: Retain and stack termination protection as the guards. Pulumi keys by URN and needs an alias to survive a rename. Same mechanism, three different names for the fix — and a team switching tools carries the assumption across and loses something.
  • CLOUD-SPECIFICWhich attribute changes force replacement is provider behaviour mirroring the underlying API. Changing an instance's image forces new on most compute resources; changing a database identifier forces new on some engines and is an in-place rename on others. Read the plan, not the docs of the tool.
  • DATABASE-SPECIFICWhat "destroyed" costs depends on the engine and its snapshot behaviour. Some managed engines take a final snapshot on deletion by default and some skip it when a flag is set; some point-in-time recovery windows survive instance deletion and some do not. Know which of those is true for your engine before you rely on it as a safety net.

Where the depth lives

This domain teaches delivery and operation, and hands the mechanism off to the domain that owns it.

Domains that do not exist yet
  • Testing & Reliability Engineering — why a change whose source diff and whose effect are unrelated defeats review as a control, and what has to replace it.