Introduction
Problem
A routine change (e.g., updating tags or a small variable) suddenly triggers a plan that destroys and recreates a large set of resources—VMs, NICs, Public IPs, Disks, NSG associations—despite no intentional renames. You see instance address changes like:
- module.vm.azurerm_network_interface.nic["app-01"] will be destroyed + module.vm.azurerm_network_interface.nic["app01"] will be created
This can cause long outages, IP changes, and extension failures even though the config changes were minor.
Cause
for_each key drift: The unique keys used in for_each changed between runs (e.g., app-01 → app01 or App01 → app01), often due to:
- Case normalization (e.g.,
lower()added later) - Removing separators (
replace("-", "")) - Using display names instead of stable identifiers
- Deriving keys from computed values (names generated in
locals) - Hidden data transformation in input (YAML/JSON maps normalize or trim keys)
Terraform treats for_each keys as identity, so a key change equals a different instance → destroy/recreate. Dependent resources then cascade replacements (NIC → VM → Extensions → Disks).
Overview of possible solutions (if applicable)
Solutions:
1) Detect key drift quickly
Run:
terraform plan -out=tfplan terraform show -json tfplan > tfplan.json # (Optional) Inspect a few changed addresses manually: terraform state list | grep module.vm.azurerm_network_interface.nic
Look for pairs like ["app-01"] vs ["app01"], or subtle punctuation/case changes.
2) Stabilize for_each keys in code
Rule of thumb: Never base keys on names that can change. Use immutable identifiers (e.g., an ID from input or a generated stable ID).
Before (risky):
# Keys derived from display names
locals {
vms = {
for vm in var.vms :
# KEY = lower(replace(vm.name, "-", "")) # <- this drifts
lower(replace(vm.name, "-", "")) => vm
}
}
resource "azurerm_network_interface" "nic" {
for_each = local.vms
name = "${each.value.name}-nic"
# ...
}After (stable):
# Provide / derive a stable key once and keep it forever
variable "vms" {
type = list(object({
id = string # immutable key (e.g., "app01")
name = string # display name (e.g., "app-01")
# other attributes...
}))
}
locals {
vms_by_id = { for vm in var.vms : vm.id => vm } # stable keys
}
resource "azurerm_network_interface" "nic" {
for_each = local.vms_by_id
name = "${each.value.name}-nic" # can change safely
# ...
}Tip: If you can’t change
var.vmsstructure, create a stable surrogate key once (e.g., hash of a canonical field) and never change that logic afterward.
3) Map existing state to new keys (no recreates)
If keys already changed, instruct Terraform to move state instead of recreating.
Option A — moved blocks (Terraform 1.1+):
moved {
from = azurerm_network_interface.nic["app-01"]
to = azurerm_network_interface.nic["app01"]
}
moved {
from = azurerm_public_ip.pip["app-01"]
to = azurerm_public_ip.pip["app01"]
}
moved {
from = azurerm_virtual_machine.vm["app-01"]
to = azurerm_virtual_machine.vm["app01"]
}Add one moved block per resource type that changed keys. Commit, then run plan → apply. Terraform will rename state instances without touching real infrastructure.
Option B — CLI state moves (if you prefer explicit commands):
# Run in a controlled environment (lock enabled) terraform state mv 'module.vm.azurerm_network_interface.nic["app-01"]' 'module.vm.azurerm_network_interface.nic["app01"]' terraform state mv 'module.vm.azurerm_public_ip.pip["app-01"]' 'module.vm.azurerm_public_ip.pip["app01"]' terraform state mv 'module.vm.azurerm_virtual_machine.vm["app-01"]' 'module.vm.azurerm_virtual_machine.vm["app01"]'
Order matters: Move leaf resources first (PIP, NIC, disks) then the VM. This prevents temporary address conflicts.
4) Guard against cascades
- Ensure dependent resources use the same stable key basis.
- Avoid
create_before_destroyunless absolutely needed. - Use
lifecycle { ignore_changes = [tags] }sparingly; it hides symptoms, not causes. - Confirm backend locking is enabled so no parallel jobs fight over state.
5) Validate with a dry run
terraform init -upgradeterraform validateterraform plan
Expect zero destroys and a small diff (if any).terraform apply
Monitor that no infrastructure is recreated.
Outcome
- No unintended deletes/recreates
- IP addresses and NIC attachments remain intact
- Shorter plan/apply times
- Predictable module behavior across environments
- Clean drift-free state tied to stable keys