OnCallReady

Lesson 13.46 · Terraform: State & Modules · 22 min read

Refactoring safely: moved, removed and lifecycle

In plain words

Imagine every toy in your room has a label with its name, and your mum keeps a list of which labels belong to which toys. If you move a teddy from the shelf to the toy box and also change its label, your mum might think the old teddy was thrown away and a new one bought. So you leave her a note: "the teddy on the shelf is now the teddy in the box". Same teddy, new place.

That note is a moved block: moved { from = azurerm_subnet.app to = module.network.azurerm_subnet.this["app"] }. It carries identity across renames, count-to-for_each switches and moves into modules, so the plan shows "has moved to" instead of destroy and create. A removed block says "stop tracking this, but keep it". lifecycle settings (prevent_destroy, create_before_destroy, ignore_changes, replace_triggered_by) control how replacements happen.

The problem

You tidy up the code: rename azurerm_subnet.app to azurerm_subnet.application, and move the subnets into the new network module. Nothing about the real subnets changed. The plan says: 4 to add, 4 to destroy. Applied, that would cut every VM off the network for minutes.

Terraform does not know you renamed something; it sees one address disappear and another appear. This lesson is about telling it, in code, so refactors destroy nothing - and about the lifecycle settings that protect objects when something really must be replaced.

What you need to know already:

Addresses are identity

Terraform matches configuration to state by address. Change an address and, as far as Terraform can tell, one object was deleted and a different one was added:

rename a resource             azurerm_subnet.app        -> azurerm_subnet.application
count to for_each             azurerm_subnet.s[1]       -> azurerm_subnet.s["app"]
move into a module            azurerm_subnet.app        -> module.network.azurerm_subnet.this["app"]
rename a module               module.net.*              -> module.network.*

Each of these, done naively, plans a destroy and a create of the same thing. Refactoring safely means carrying the old identity across to the new address.

moved blocks (Terraform 1.1+)

A moved block says "the object at from is now at to":

moved {
  from = azurerm_subnet.app
  to   = azurerm_subnet.application
}

The plan then shows the move and nothing else:

  # azurerm_subnet.app has moved to azurerm_subnet.application
    resource "azurerm_subnet" "application" {
        id   = "/subscriptions/.../subnets/app"
        name = "app"
        # (3 unchanged attributes hidden)
    }

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

has moved to is the line you want to see; the ID stayed the same, so it is the same real subnet.

Everything moved can express:

# rename a resource
moved {
  from = azurerm_storage_account.logs
  to   = azurerm_storage_account.diagnostics
}

# one instance: count index to for_each key
moved {
  from = azurerm_subnet.s[0]
  to   = azurerm_subnet.s["web"]
}

# a resource into a module (all instances, keys kept)
moved {
  from = azurerm_subnet.this
  to   = module.network.azurerm_subnet.this
}

# rename a module call
moved {
  from = module.net
  to   = module.network
}

# a module call gaining for_each
moved {
  from = module.network
  to   = module.network["hub"]
}

Rules:

moved vs state mv

                    state mv                        moved block
where               your shell, now                 the code, in a PR
review              none - it just happens          the plan shows every move
other callers       only this state                 every state that uses the module, on upgrade
undo                another state mv                delete the block before applying

moved is the default. state mv is for a one-off repair on one state.

Leaving moved blocks in

In a root configuration, a moved block can be deleted once it has been applied everywhere that code runs (all environments). In a shared module, keep them for a long time: callers still on the old version need them when they upgrade. Removing one is a breaking change.

removed blocks (Terraform 1.7+)

A removed block (13.30) is the reviewable state rm:

removed {
  from = azurerm_storage_account.legacy

  lifecycle {
    destroy = false
  }
}
│ Error: Removed resource still exists
│
│ This statement declares that azurerm_storage_account.legacy was removed, but it
│ is still declared in configuration.

lifecycle

A lifecycle block inside a resource changes how Terraform creates, replaces and destroys it. All of its settings:

lifecycle {
  create_before_destroy = true
  prevent_destroy       = true
  ignore_changes        = [tags]
  replace_triggered_by  = [azurerm_key_vault_secret.tls.version]

  precondition  { ... }
  postcondition { ... }
}

create_before_destroy

A replacement normally destroys first, then creates (-/+ in the plan). With create_before_destroy (CBD) the new object is created first and the old one destroyed after (+/-). Things that depend on it are never left without one.

The Azure catch: most names must be unique. A new storage account or Key Vault with the same name cannot be created while the old one still exists:

│ Error: A resource with the ID "/subscriptions/.../storageAccounts/stordersdata"
│ already exists - to be managed via Terraform this resource needs to be imported

So create_before_destroy only works when the replacement gets a different name:

The setting also spreads: resources that a CBD resource depends on are treated as create-before-destroy too.

prevent_destroy

prevent_destroy = true makes any plan that would destroy the object fail:

│ Error: Instance cannot be destroyed
│
│ Resource azurerm_key_vault.payments 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.

ignore_changes

For attributes another system owns (13.21). Also for values that are only an initial setting: ignore_changes = [node_count] on a group of machines whose count an autoscaler (13.21) takes over after creation.

replace_triggered_by (1.2+)

Replace this resource whenever something it depends on changes, even though none of its own arguments changed:

resource "azurerm_linux_virtual_machine" "app" {
  # ...
  lifecycle {
    replace_triggered_by = [azurerm_key_vault_secret.bootstrap.version]
  }
}
  # azurerm_linux_virtual_machine.app will be replaced due to changes in replace_triggered_by

depends_on inside refactors

When you move resources into a module, references that used to order things can disappear (the caller may now pass only names). If the plan starts creating things in the wrong order, pass the outputs that carry the dependency (subnet_id = module.network.subnet_ids["app"]). Avoid depends_on = [module.network]: it makes the whole module wait and delays its data sources to apply time (13.34).

The refactoring checklist

  1. Change the code and add moved blocks in the same commit.
  2. terraform plan - every change is has moved to; the summary is 0 to add, 0 to change, 0 to destroy (or exactly what you intended).
  3. Read every must be replaced line that remains - each is a missing move or a real argument change.
  4. Apply in every environment that uses the code.
  5. Keep moved blocks inside modules until every caller has upgraded.

What you can now do:

Why it helps

Refactoring is where good intentions cause outages: tidying a flat configuration into modules plans to destroy and recreate the production VNet. With moved blocks the same PR plans "0 to add, 0 to change, 0 to destroy", and your review checklist makes every leftover must be replaced a question. create_before_destroy fails on Azure's unique names unless you add a random suffix, which you will explain to a teammate staring at "already exists". prevent_destroy on the payments Key Vault stops a destroy plan, but not someone deleting the block. And replace_triggered_by is the answer to "rebuild the VM when its bootstrap secret rotates". Refactoring without destroying is a core interview question.

FAQ

Can I delete moved blocks after applying them?

In a root configuration, yes, once the move has been applied to every state that uses that code (all environments). In a shared module, keep them for a long time: callers still on an older version need the moves when they upgrade, and removing them is a breaking change. A moved block whose job is done is harmless, since the move is skipped when the destination already has the object.

Why does create_before_destroy fail with "already exists" on Azure?

Most Azure names are unique within a scope or globally. With create-before-destroy, Terraform creates the replacement first, while the old object with the same name still exists, so Azure refuses. It works only when the replacement gets a different name: a random_id or random_pet suffix that changes whenever replacement is needed, or a provider's name_prefix-style argument.

Does prevent_destroy protect a resource if someone deletes its block?

No. It only fails plans that would destroy a resource still declared in the configuration: destroy, a forced replacement, removal of its key from a for_each. Delete the block and the protection goes with it; the next plan destroys it normally. It guards against accidents in Terraform; Azure resource locks and RBAC are the real second layer.

What is the difference between a removed block and deleting the resource block?

Deleting the block plans a destroy of the object. A removed block with lifecycle { destroy = false } (1.7+) removes it from state and leaves it running, the reviewable form of state rm. With destroy = true, it is the same as deleting the block, but explicit. The resource block must be gone for a removed block to be valid.

Can moved change the resource type?

Generally no: a resource moves to another address of the same type. Since Terraform 1.8, a provider can declare cross-type moves it supports, for example null_resource to terraform_data. azurerm types are not interchangeable, so when a provider renames a resource type you use removed with destroy = false plus import to the new type.

In an interview Mid

What is a moved block, and when would you use it instead of terraform state mv?

A moved block tells Terraform that the object at from now lives at to, so a refactor carries the old identity across instead of planning a destroy and a create:

moved {
  from = azurerm_subnet.app
  to   = module.network.azurerm_subnet.this["app"]
}

It covers renames, count to for_each, moving into or between modules. The plan shows has moved to and 0 to add, 0 to destroy.

Use moved by default: it is in the code, reviewed in a PR, applied by every environment's next apply, and a shared module ships it to every caller who upgrades. state mv runs from your shell against one state, unreviewed - for a one-off repair.

Related: a removed block (with destroy = false) is the reviewable state rm, and lifecycle settings - prevent_destroy, create_before_destroy, ignore_changes, replace_triggered_by - protect objects that really must change.

Also asked: What does create_before_destroy do, and why does it often fail on cloud resources with unique names? · What does prevent_destroy protect against, and what does it not? · How do you refactor a flat configuration into modules without destroying anything?

Practise this lesson in the terminal Free, in your browser - a real Ubuntu terminal to try it in, with missions that check your work.