Problem
A routine configuration change, such as updating tags, triggers a Terraform plan that proposes to destroy and recreate a large set of resources, including virtual machines, network interfaces, public IPs, and disks. The plan indicates resource address changes, even though no resources were intentionally renamed.
Example plan output:
- module.vm.azurerm_network_interface.nic["app-01"] will be destroyed + module.vm.azurerm_network_interface.nic["app01"] will be created
This behavior can cause significant outages, IP address changes, and failures in virtual machine extensions, despite the minor nature of the initial configuration change.
Cause
This issue is caused by for_each key drift. The unique keys used in the for_each meta-argument changed between Terraform runs. Terraform treats for_each keys as the unique identity for each resource instance. If a key changes, Terraform interprets this as a request to destroy the instance associated with the old key and create a new one with the new key. This change cascades to all dependent resources.
Common causes of key drift include:
- Case normalization: Applying a function like
lower()to keys that were not previously normalized. - Separator removal: Using a function like
replace("-", "")on keys. - Unstable key sources: Using display names or other mutable values instead of stable, unique identifiers for keys.
- Computed values: Deriving keys from values that are computed during the Terraform run.
- Data transformation: Input from YAML or JSON sources may be implicitly normalized or trimmed.
Solutions
Solution 1: Detect Key Drift
First, confirm that key drift is the cause. Generate a plan file and inspect it to identify the exact changes in the resource instance keys.
Create a plan file.
$ terraform plan -out=tfplan
Convert the plan to JSON for easier inspection.
$ terraform show -json tfplan > tfplan.json
List the current state to compare addresses. Look for pairs with subtle differences, such as
["app-01"]versus["app01"].$ terraform state list | grep module.vm.azurerm_network_interface.nic
Solution 2: Stabilize for_each Keys in Configuration
To prevent future drift, modify your configuration to use immutable identifiers for for_each keys. Never base keys on mutable values like display names.
Before (Unstable Keys):
This example derives keys from display names, which can change.
# 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 Keys):
This example uses a dedicated, immutable id attribute for the key, while the mutable name can change safely.
# Provide or 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
# ...
}If you cannot change the input variable structure, you can create a stable surrogate key, for example by using the hash of a canonical field, and ensure that logic never changes.
Solution 3: Remap State to New Keys Without Recreation
If the keys have already drifted in your configuration, you can instruct Terraform to update the state addresses instead of recreating the infrastructure.
Option A: Use moved Blocks (Terraform 1.1+)
Add a moved block to your configuration for each resource that was affected by the key change. This declaratively tells Terraform to rename the instance in the state file.
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"]
}After adding the blocks, run terraform plan and terraform apply. Terraform will update the state without modifying your infrastructure.
Option B: Use terraform state mv
Alternatively, you can use the terraform state mv command to manually rename each resource instance in the state. Run these commands in a controlled environment with state locking enabled.
Move leaf resources (like IPs and NICs) before moving dependent resources (like VMs) to avoid temporary conflicts.
$ 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"]'
Solution 4: Implement Safeguards to Prevent Cascading Changes
Adopt these best practices to minimize the impact of future changes:
- Ensure all dependent resources use the same stable key source.
- Avoid using the
create_before_destroylifecycle argument unless necessary, as it can hide underlying issues. - Use
lifecycle { ignore_changes = [tags] }sparingly. It can mask symptoms of drift rather than addressing the root cause. - Confirm that your state backend has locking enabled to prevent concurrent operations from corrupting the state.
Solution 5: Perform a Final Validation
After applying a fix, run a dry run to validate that the issue is resolved and no destructive changes are planned.
Initialize Terraform and upgrade providers.
$ terraform init -upgrade
Validate the configuration syntax.
$ terraform validate
Generate a plan and confirm that it proposes zero resource destructions.
$ terraform plan
Apply the changes, monitoring the output to ensure no infrastructure is recreated.
$ terraform apply
Outcome
By implementing these solutions, you can achieve the following outcomes:
- No unintended resource destructions or recreations.
- Stable IP addresses and network interface attachments.
- Faster and more predictable
planandapplyoperations. - A clean state file where resource instances are tied to stable, immutable keys.