Skip to main content
RunBook Academy

← All break/fix scenarios in Terraform

advancedterraform-state~30 min

moved Block Maps Resources in Wrong Direction

Reported symptoms

  • ●A pull request reviewed as a pure refactor - convert three subnets from count to for_each - now produces a plan that replaces two of them
  • ●The refactor apply itself reported success: three moves, and 0 added, 0 changed, 0 destroyed
  • ●Two of the three subnets are marked for replacement and the third is clean, which reads as a per-resource fault rather than a systematic one
  • ●The route table associations for those two subnets are also marked for replacement, and so is the NAT gateway that sits in one of them
  • ●terraform state list shows exactly the three expected for_each addresses; nothing is missing and nothing is duplicated
  • ●A second engineer on a fresh clone with an empty plugin cache gets the same plan, so a stale working copy was ruled out in the first ten minutes

Evidence

  • · The three moved blocks as merged: index 0 maps to the eu-west-2c key and index 2 maps to the eu-west-2a key
  • · terraform state show for the eu-west-2a key reports availability_zone eu-west-2c and cidr_block 10.0.2.0/24
  • · The plan annotates availability_zone and cidr_block with # forces replacement on both affected subnets
  • · terraform providers schema -json reports force_new true for both of those attributes on aws_subnet
  • · The state version written immediately before the refactor apply, still held by the versioned backend, records the original index-to-object binding
  • · Nothing changed in the account: the three subnets have the same ids, in the same three zones, created eleven months ago
Diagnosis and resolutionclick to reveal

Root cause

A moved block is not a hint; it is an instruction to rewrite state, and Terraform carried this one out exactly as written. Converting a count-indexed collection to for_each needs one moved block per instance, because the mapping from index to key is data rather than a rename - it exists nowhere in the configuration diff and nowhere in the real infrastructure. The three blocks were written from two lists side by side, the index column read top to bottom and the key column read bottom to top, so the mapping ran backwards: index 0 landed on the eu-west-2c key and index 2 landed on the eu-west-2a key. Both moves succeeded, because both are legal address rewrites and Terraform has no way to know which real object belongs under which key. State now records the subnet that physically lives in eu-west-2c at the address whose configuration says eu-west-2a. Neither availability_zone nor cidr_block can be changed on an existing subnet, so the provider marks the difference as forcing replacement, and the next plan proposes to destroy two healthy subnets along with everything whose identity depends on them. The middle instance is untouched only because it is the fixed point of the reversal.

Remediation

Do not apply, and freeze the workspace before anything else: a second engineer running apply on the same branch is how this becomes an outage. The instinct - add a corrective pair of moved blocks that swaps the two keys back - does not work. Terraform reads a sequence of moved blocks as one object's move history and follows the chain, so two blocks pointing at each other are not a swap it can execute. The blocks that already ran are spent as well: their from addresses no longer exist in state, so editing them changes nothing on their own. Two routes remain. If the versioned backend still holds the serial written immediately before the refactor apply, and nothing else has been applied since, restoring that version is a single atomic revert and is the cheaper option. Otherwise rewrite the binding imperatively: back the state up with terraform state pull, build a table of address, real resource id and real availability zone, park one of the two crossed objects at an address the configuration does not declare so that its destination frees up, then move the other two into place with terraform state mv. Correct the moved blocks in the same change, or delete them, so that a state restore months from now cannot replay the reversal.

Verification

The check is that every subnet keeps the id it had before the refactor, not that a plan came back green. Record the three ids and zones before the fix and compare them afterwards with terraform state show; the object at each key must be the subnet that physically lives in that zone, and the id is the only field that proves it. Then plan and require No changes, with no replacement prefix anywhere in the output - including on the route table associations and the NAT gateway, which were only ever collateral and must fall away on their own once the subnet ids stop changing. A plan that still replaces one of those means a move is still wrong, not that the collateral needs its own fix. Confirm the state serial advanced once per move and that the lock was released, and confirm the stale moved blocks are gone from the configuration.

Prevention

Treat an index-to-key mapping as data, not as a rename. A count to for_each conversion is the one refactor whose moved blocks carry information that appears nowhere in the diff, so generate them from state rather than typing them, and review them against terraform state show output rather than against the configuration - the configuration cannot disagree with them. Ship the mapping in its own pull request with the table of index, real resource id and real zone in the description, so that a reviewer can check the pairing without reconstructing it. Read the first plan after a moved block as carefully as an apply: every move line names both addresses, and that line is the last cheap opportunity to notice the mapping runs backwards. Prefer keys that can be read back off the real object, so that a correct pairing can be recovered from the estate rather than from the author's memory. Rehearse the conversion in a non-production workspace, where a crossed binding costs a re-plan instead of two subnets.

Reported symptoms

The change was small enough that nobody argued about it. The production VPC declares its three public subnets with count = 3, and the team wants them keyed by availability zone so that removing a zone stops re-indexing the survivors. The pull request converts the resource to for_each, adds a locals map, and carries three moved blocks so that Terraform treats the change as a rename rather than a rebuild.

locals {
  public_subnets = {
    "eu-west-2a" = "10.0.0.0/24"
    "eu-west-2b" = "10.0.1.0/24"
    "eu-west-2c" = "10.0.2.0/24"
  }
}

resource "aws_subnet" "public" {
  for_each          = local.public_subnets
  vpc_id            = aws_vpc.main.id
  cidr_block        = each.value
  availability_zone = each.key
}

It was reviewed as a no-op, applied on Thursday afternoon, and it reported what a no-op reports:

aws_subnet.public[0] has moved to aws_subnet.public["eu-west-2c"]
aws_subnet.public[1] has moved to aws_subnet.public["eu-west-2b"]
aws_subnet.public[2] has moved to aws_subnet.public["eu-west-2a"]

Apply complete! Resources: 0 added, 0 changed, 0 destroyed.

Friday’s plan, from the same branch with nothing further merged, proposes to destroy and recreate two of the three subnets, both route table associations that reference them, and the NAT gateway.

Four things about that plan sent the investigation the wrong way. Two subnets are replaced and the third is clean, which looks like a fault in two particular resources rather than in the refactor. The NAT gateway is in the list, which makes it look like a networking change. terraform state list returns exactly the three addresses the new configuration declares, so nothing is obviously missing. And a colleague on a fresh clone gets the same plan, which retires the usual first suspect.

Evidence provided

The three moved blocks, exactly as merged:

moved {
  from = aws_subnet.public[0]
  to   = aws_subnet.public["eu-west-2c"]
}

moved {
  from = aws_subnet.public[1]
  to   = aws_subnet.public["eu-west-2b"]
}

moved {
  from = aws_subnet.public[2]
  to   = aws_subnet.public["eu-west-2a"]
}

What state now holds under the first key:

# READ-ONLY: brackets and quotes are meaningful, so quote the whole address
terraform state show 'aws_subnet.public["eu-west-2a"]'
# aws_subnet.public["eu-west-2a"]:
resource "aws_subnet" "public" {
    id                = "subnet-0ccc"
    availability_zone = "eu-west-2c"
    cidr_block        = "10.0.2.0/24"
    vpc_id            = "vpc-0abc"
}

The plan, trimmed to the lines that carry the diagnosis:

  # aws_subnet.public["eu-west-2a"] must be replaced
-/+ resource "aws_subnet" "public" {
      ~ availability_zone = "eu-west-2c" -> "eu-west-2a" # forces replacement
      ~ cidr_block        = "10.0.2.0/24" -> "10.0.0.0/24" # forces replacement
      ~ id                = "subnet-0ccc" -> (known after apply)
    }

  # aws_subnet.public["eu-west-2b"] is up to date

  # aws_subnet.public["eu-west-2c"] must be replaced
-/+ resource "aws_subnet" "public" {
      ~ availability_zone = "eu-west-2a" -> "eu-west-2c" # forces replacement
      ~ cidr_block        = "10.0.0.0/24" -> "10.0.2.0/24" # forces replacement
      ~ id                = "subnet-0aaa" -> (known after apply)
    }

And the provider’s own answer on why those two attributes force a replacement rather than an update:

# READ-ONLY: ask the schema instead of trusting a memory of the API
terraform providers schema -json |
  jq '.provider_schemas
      | to_entries[]
      | select(.key | endswith("/aws"))
      | .value.resource_schemas.aws_subnet.block.attributes
      | {availability_zone, cidr_block}'

Nothing changed in the account. The three subnets have the same ids they had eleven months ago, in the same three zones.

Work the evidence before reading on

The apply that “did nothing” is the apply to interrogate.

  1. Line up the from column of the three moved blocks against the to column. What is the ordering relationship between them?
  2. terraform state show for the eu-west-2a key reports a subnet in eu-west-2c. Which of the two - the address or the object - is Terraform treating as authoritative, and which one does it have no way to check?
  3. The middle subnet is clean. What property of the mapping makes exactly one of three instances survive?
  4. The NAT gateway is in the plan and nobody touched it. Which attribute of the NAT gateway changed, and where did the new value come from?

Before continuing: the obvious fix is a second pair of moved blocks that swaps the two keys back. Write them out, then work out what Terraform does with a moved block whose to address is another block’s from.

Root cause

1. The mapping is data, and the data was reversed

A rename is a rename: moved { from = aws_instance.web, to = aws_instance.app } is checkable against the diff, because the diff contains both names. A count to for_each conversion is not a rename. The block that changes is one block; what changes underneath it is three identities, and the pairing between index and key exists in exactly one place - the moved blocks themselves. There is nothing to check it against.

The three blocks were written by hand from two lists placed side by side: the index list read top to bottom, the key list read bottom to top. Index 0 landed on eu-west-2c, index 2 landed on eu-west-2a, and index 1 landed correctly because it is the fixed point of a reversal of three.

2. Terraform did exactly what it was told, and could not have known better

The state maps an address to a real object. When a moved block declares that the object at aws_subnet.public[0] now lives at aws_subnet.public["eu-west-2c"], Terraform rewrites the address on that state entry and nothing else. It does not read the object. It does not compare the object’s availability_zone against the key. Both of these blocks are well-formed, both source addresses existed in state, both destination addresses exist in configuration, and the result is a clean apply with nothing added, changed or destroyed.

That clean apply is the most misleading artefact in the incident. It is a true statement about infrastructure and it says nothing at all about correctness.

3. The replacement is the provider’s decision, not a mistake

Once the binding is crossed, the plan is straightforward. Under the eu-west-2a key, configuration asks for a subnet in eu-west-2a with 10.0.0.0/24; state holds a subnet in eu-west-2c with 10.0.2.0/24. Neither attribute can be changed on a live subnet, so the provider’s schema marks both force_new, and Terraform converts the difference into a destroy-and-create. The plan is correct. The premise it was given is not.

4. Why the NAT gateway is in the plan

Nothing touched the NAT gateway. Its subnet_id is a reference to one of the replaced subnets, and a replaced subnet’s id becomes (known after apply). subnet_id also forces replacement, so a value that will not be known until apply time turns into a proposed replacement, which then propagates to the route table associations for the same reason. This is why the symptom set looks like four problems: one crossed binding, expressed through every resource whose identity depends on a subnet id.

Resolution

  1. Do not apply, and freeze the workspace. Say it out loud in the incident channel. The plan is sitting on a branch that is already merged, so anyone running an apply for an unrelated reason applies this one.

  2. Capture the current state before touching it. terraform state pull to a file, and record the backend’s version id for the serial written by the refactor apply and for the serial immediately before it.

  3. Build the mapping table. One row per key: the for_each key, the resource id state holds under it, and the availability zone that object is really in. The id is the field that settles every subsequent argument; nothing else in the state entry is proof of identity.

  4. Choose the route. If the versioned backend still holds the serial from immediately before the refactor apply, and no other change has been applied since, restoring that version is one atomic operation that returns the state to a binding you know was correct. Everything below is the route for when that is not true - when other applies have landed, or when the backend does not keep versions.

  5. Park one crossed object. Both destinations are occupied, so one has to move out of the way first. Move it to an address the configuration does not declare:

    terraform state mv 'aws_subnet.public["eu-west-2a"]' 'aws_subnet.public_crossed'
  6. Move the other two into place, lowest-cost destination first.

    terraform state mv 'aws_subnet.public["eu-west-2c"]' 'aws_subnet.public["eu-west-2a"]'
    terraform state mv 'aws_subnet.public_crossed' 'aws_subnet.public["eu-west-2c"]'

    The parking address exists only between two commands. Do not leave state in that shape, do not approve a plan taken while it is - the parked object has no configuration block and a plan will propose to destroy it - and do not walk away between the two moves.

  7. Correct the configuration in the same change. Fix the three moved blocks so they describe the mapping that is now true, or delete them, which after a successful move is the cleaner option. Leaving the reversed blocks in the repository is a live hazard: they do nothing today and they fire again the moment anyone restores a state version from before the refactor.

  8. Re-plan and read the whole output. Not the summary line.

Verification

  1. Every subnet keeps its original id. Compare the mapping table from step 3 against terraform state show for all three keys. The object under ["eu-west-2a"] must be the subnet that is physically in eu-west-2a. This is the check that can fail, and it is the only one that proves the binding rather than the absence of a diff.
  2. The plan reports No changes. With no -/+ and no +/- prefix anywhere - the summary line cannot tell you this, because a replacement is counted once as an add and once as a destroy.
  3. The collateral fell away on its own. The route table associations and the NAT gateway must be absent from the plan without anyone having touched them. If one is still there, a subnet id is still changing and a move is still wrong. Fixing the association directly at that point buries the remaining error.
  4. The state serial advanced once per move and the lock is released.
  5. No reversed moved block remains in the repository. Grep for it. This is the item most likely to be skipped, and it is the one that makes the incident repeatable.
  6. The account is unchanged. Three subnet ids, three zones, same creation timestamps as before the refactor. Nothing in this incident should have reached the cloud, and the evidence for that claim is the ids.

Prevention

  • Generate the mapping, do not type it. The index-to-key pairing is the one part of a count to for_each conversion that carries information found nowhere else. Derive it from terraform state list and the real attribute that becomes the key, and let the tool that reads state write the blocks.
  • Review the blocks against state, not against the diff. The configuration cannot contradict a moved block, so a reviewer comparing the two learns nothing. terraform state show for each source address can contradict it.
  • Put the mapping table in the pull request description. Index, real resource id, real zone, destination key. A reviewer who cannot check the pairing from the PR alone is approving the author’s arithmetic.
  • Read the move lines in the first plan. They name both addresses. That plan is the last point at which this costs nothing.
  • Prefer keys that can be read back off the object. An availability zone, a name tag, a stable external identifier - anything where the correct pairing can be recovered from the estate rather than from whoever wrote the blocks.
  • Rehearse the conversion in a non-production workspace. A crossed binding there costs a re-plan.