Terraform Professional study materials
Terraform Professional study resources

The Subnet Refactor That Wants to Destroy 120 Resources

Terraform Professional · Put your knowledge to work in context.

All case studies

The Subnet Refactor That Wants to Destroy 120 Resources

A platform team manages 4 AWS accounts from one root module. Network subnets are declared as count = length(var.subnets) over a list of 40 objects (name, cidr, az). After a refactor to for_each = { for s in var.subnets : s.name => s }, terraform plan against the existing state proposes destroying and recreating 120 resources across the 4 accounts, including subnet-to-route-table associations. The team also added lifecycle { ignore_changes = [tags] } to every resource, because a nightly cost-allocation cron job rewrites tag values and produced permanent diffs. The refactor must land before a compliance deadline, and the shared NAT subnets cannot take an outage.

1. The subnet definitions did not change, yet the plan replaces 120 resources. Explain the cause and describe exactly how to land the refactor with zero resource replacement.

Read the worked answer

Cause: the resource address changed. With count, each instance is keyed by its position (aws_subnet.this[0]); with for_each, it is keyed by map key (aws_subnet.this["private-a"]). Terraform cannot match an index to a key, so it plans a destroy and a create for every instance. The data at the AWS API is irrelevant here - this is purely state addressing.

Fix without touching the API: rewrite the addresses in state.

moved {
  from = aws_subnet.this[0]
  to   = aws_subnet.this["private-a"]
}

Key points that decide whether this works:

  1. moved blocks are static - you cannot generate them from for_each or count. Emit one block per subnet from the old index→name mapping in a script, commit them, and keep them in the module for at least one release so every consumer's state converges.
  2. Repeat for every child resource keyed the same way (route table associations, NACL associations). If those were keyed by a flattened local.pairs list, the indices shift too - map them separately or use terraform state mv address pairs.
  3. A moved block errors if the source address is absent from state, so this must run in each account's own state, not once globally.
  4. Verify before applying: plan must show 0 to add, 0 to change, 0 to destroy in all 4 accounts. Do it with -lock-timeout=5m and never while a pipeline is mid-apply.

2. Evaluate the `lifecycle { ignore_changes = [tags] }` decision and state what you would change.

Read the worked answer

It silences a symptom and creates a bigger one. ignore_changes = [tags] is applied to the whole tags map, so it does two things at once:

  • It hides all drift, not just the cron job's rewrites. Someone deleting an Owner or DataClass tag in the console will never appear in a plan. For a team with a compliance deadline, tag-based access control and cost allocation silently rot.
  • It also blocks legitimate changes. If an engineer edits tags in the configuration, Terraform plans nothing. The module becomes the source of truth for everything except tags, and nobody will remember why.

The narrow fix is to scope the ignore to the single drifting key: ignore_changes = [tags["LastModified"]]. That keeps the cron noise out and leaves every other tag under management.

One constraint to be aware of: lifecycle arguments must be literal - you cannot reference variables, so you cannot make this behaviour configurable per consumer inside one module. Real options are the narrow ignore above, or separate module variants.

For the compliance angle, drift detection is better handled outside the resource: a check block (or policy-as-code in the pipeline) that asserts required tags exist, which reports the violation instead of muting it. Note that check failures are reported as warnings and do not halt the operation, so treat them as monitoring plus a pre-apply policy gate, not a guardrail on their own.

3. A consumer passes a subnet CIDR outside the VPC range. The plan succeeds and apply fails against the API. How would you make this fail earlier, and what are the limits of `variable validation` here?

Read the worked answer

The limit first: a variable validation condition may only reference the variable being declared. A validation on subnet_cidrs that compares against var.vpc_cidr is a hard error ("Invalid reference in variable validation"), so the natural check is simply not expressible at the variable level. Per-variable validation is fine for shape (non-empty list, CIDR syntax via can(cidrhost(s, 0))), not for cross-variable containment.

Options for containment:

  1. A resource precondition (Terraform 1.2+), which does halt the plan:
resource "aws_subnet" "this" {
  lifecycle {
    precondition {
      condition     = tonumber(split("/", cidrhost(var.vpc_cidr, 0))[0]) <= tonumber(split("/", each.value.cidr)[0])
      error_message = "subnet ${each.value.name} is not inside ${var.vpc_cidr}"
    }
  }
}

A check block spanning both variables is more readable and reports every offending subnet at once, but its failures surface as warnings rather than stopping the run - useful for a periodic audit, not for a hard gate.

  1. A terraform test run block asserting the failing input via expect_failures, so the guard is proven in CI rather than discovered in a consumer's account.