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 and
state mv/state rm(13.1, 13.15) removedblocks for moving between states (13.30)- modules and their addresses (13.34)
ignore_changes(13.21), preconditions and postconditions (12.10)- reading
-/+(destroy then create) in a plan (12.24)
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:
fromandtoare addresses, not strings - no quotes around them.- A resource moves to a resource of the same type. (Since 1.8 a provider can declare that it supports moving between types, e.g. from
null_resourcetoterraform_data- two "do nothing, just hold values" resources; azurerm types cannot be swapped this way.) - The
toaddress must exist in the configuration; thefromaddress must not. - Moves chain:
a -> b, then laterb -> c, works for states still ata. - If the destination already has an object in state, the move is skipped. So a
movedblock that has already done its job is harmless.
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
}
}
destroy = false: forget the object; it keeps existing. The plan warns: Some objects will no longer be managed by Terraform.destroy = true(or no lifecycle block): destroy it - the same as deleting the block, but explicit and reviewable.- The resource block must be gone. A
removedblock for something still declared is an error:
│ Error: Removed resource still exists
│
│ This statement declares that azurerm_storage_account.legacy was removed, but it
│ is still declared in configuration.
fromcan be a whole module:from = module.legacy_network.
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:
- a suffix from
random_idorrandom_pet(random-provider resources that make a random string or word pair) that changes whenever a replacement is needed; - or a
name_prefix-style argument, where a provider offers one, so the provider adds a unique ending itself.
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.
- Any plan that would destroy it fails:
destroy, a forced replacement, or removing its key from afor_each. - It protects only what is still in the configuration. Delete the resource block and the protection goes with it; the next plan destroys it normally.
- It is a guard against accidents in Terraform, not access control. Azure resource locks (13.4) are the second layer.
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
- It takes a resource (
azurerm_x.y- any change to it triggers) or one of its attributes (azurerm_x.y.version- only that attribute). - Common uses: rebuild VMs when the secret they start with is rotated (replaced with a new value); recreate something when an image version changes.
- For a plain value (a variable), store it in a
terraform_dataresource and use that as the trigger -replace_triggered_byonly accepts resources.
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
- Change the code and add
movedblocks in the same commit. terraform plan- every change ishas moved to; the summary is 0 to add, 0 to change, 0 to destroy (or exactly what you intended).- Read every
must be replacedline that remains - each is a missing move or a real argument change. - Apply in every environment that uses the code.
- Keep
movedblocks inside modules until every caller has upgraded.
What you can now do:
- refactor names,
counttofor_each, and into modules withmovedblocks and a plan that destroys nothing - give up an object with a
removedblock - choose the right
lifecyclesetting: CBD,prevent_destroy,ignore_changes,replace_triggered_by