Back to Terraform & IaC
Q10Question 10 of 13

Ensuring a Terraform change does NOT unexpectedly recreate resources

Key Concepts
  • Read the plan carefully: + (create), ~ (update in place), - (destroy), -/+ (replace) have vastly different risk levels
  • Immutable attributes like availability_zone, engine, cidr_block, and bucket name force replacement when changed
  • Pinning provider and module versions prevents defaults from shifting silently under you
  • Every change should go through a PR where terraform plan output is attached and a second engineer reviews it
  • lifecycle blocks: prevent_destroy blocks deletion, create_before_destroy avoids downtime, ignore_changes stops spurious diffs
  • terraform state mv renames a resource in state without recreating it, avoiding unnecessary replacement

Definition

Read the plan, know which attributes are immutable, pin versions, review in PRs, and guard stateful resources with prevent_destroy: IaC is a controlled change process, not just plan and apply.
  • Read the plan to distinguish create, update-in-place, destroy, and replace actions for every resource
  • Know immutable attributes that force replacement when changed, such as availability_zone, engine, cidr_block, and bucket name
  • Pin provider and module versions so defaults do not shift under you between runs
  • Review plans in pull requests with a second engineer reading every -/+ line
  • Apply lifecycle blocks like prevent_destroy, create_before_destroy, and ignore_changes deliberately to guard stateful resources
  • Use terraform state mv to rename resources in state without recreating them when a rename would otherwise force replacement

The National Bank of India treats changing live infrastructure like moving money between Rajesh's and Priya's accounts: every step is checked, logged, and approved, never done casually. Before any change touches the vault, a second officer reads the instruction slip (the plan) and asks "does this just update a record, or does it empty and recreate the account?" If it would recreate the account, they put a hard lock on it (prevent_destroy) so the system physically refuses. Some fields on the account change outside the bank's control, like an auto-updated timestamp, and the bank tells the system to ignore those so they do not trigger a pointless rewrite (ignore_changes).

Understanding the Concepts

What are the plan symbols and what do they mean?

The first line of defence is simply reading the plan carefully. Terraform prints a symbol for each resource it will change, and each symbol carries a very different risk level.

TEXT
# Terraform plan symbols
  +   Create a new resource          (low risk)
  -   Destroy an existing resource    (high risk)
  ~   Update in place                 (low risk, no recreation)
 -/+  Replace (destroy then create)   (high risk, data loss possible)
  <=  Read (data source refresh)      (no risk)
TEXT
# Example plan output for NBI transfer infrastructure
  # aws_instance.transfer_service will be updated in-place
  ~ resource "aws_instance" "transfer_service" {
      ~ tags = { "version" = "2.1" -> "2.2" }   # safe update
    }

  # aws_db_instance.ledger must be replaced
-/+ resource "aws_db_instance" "ledger" {
      ~ engine_version = "13.4" -> "15.2"  # forces replacement!
    }

Plan: 1 to add, 1 to change, 1 to destroy.

If you only glance at the summary "1 to change" you can miss that the change is actually a destroy-and-recreate of the production database.

Which attributes are immutable and force replacement?

Immutable attributes are ones the cloud API cannot change on an existing object, so Terraform must replace the whole resource. Knowing these lets you anticipate a replacement before you even run the plan.

| Resource | Immutable Attribute | Effect of Change |
|---|---|---|
| aws_instance | availability_zone | Destroy + recreate instance |
| aws_db_instance | engine | Destroy + recreate database |
| aws_subnet | cidr_block | Destroy + recreate subnet |
| aws_s3_bucket | bucket (name) | Destroy + recreate bucket |
| aws_iam_user | name | Destroy + recreate IAM user |
| aws_lambda_function | function_name | Destroy + recreate function |

HCL
# Mutable attributes (safe to change in place):
# - aws_instance: instance_type, tags, security_groups
# - aws_db_instance: instance_class, allocated_storage (increase)
# - aws_security_group: description, ingress/egress rules, tags

# Immutable attributes (force replacement):
# - aws_instance: availability_zone, ami (sometimes)
# - aws_db_instance: engine
# - aws_subnet: cidr_block
# - aws_s3_bucket: bucket name

How does pinning versions prevent surprises?

A provider upgrade can change a default or recompute an attribute, silently turning a no-op into a replacement. Pinning removes this whole class of surprise.

HCL
# versions.tf -- pin everything
terraform {
  required_version = ">= 1.5.0, < 1.7.0"

  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 5.30.0"   # Accept 5.30.x patches only
    }
  }
}

module "networking" {
  source  = "git::https://github.com/nbi/terraform-networking.git?ref=v4.2.1"
  # Pinned to immutable tag, not 'main' branch
}
BASH
# WRONG: Unpinned provider upgrades silently on terraform init
terraform {
  required_providers {
    aws = { source = "hashicorp/aws" }   # No version constraint!
  }
}

# CORRECT: Pinned to a bounded range
terraform {
  required_providers {
    aws = { source = "hashicorp/aws", version = "~> 5.30.0" }
  }
}

How do you review plans in pull requests?

Every Terraform change goes through a pull request where a terraform plan is attached and a second engineer reads it. Policy tools like OPA or Sentinel can automatically fail the pipeline if the plan contains a destroy of a tagged-critical resource.

__CODE_BLOCK_5__
\n' + plan + '\n```'
});

__CODE_BLOCK_6__
hcl
resource "aws_db_instance" "ledger" {
identifier = "nbi-ledger-prod"
engine = "postgres"
instance_class = "db.r6g.xlarge"

lifecycle {
# Guard 1: Terraform errors out rather than destroying this resource
prevent_destroy = true

# Guard 2: If replacement is unavoidable, create new before removing old
create_before_destroy = true

# Guard 3: Stop fighting over attributes mutated by external processes
ignore_changes = [tags["LastBackup"], latest_restorable_time]
}
}

__CODE_BLOCK_7__
text
# prevent_destroy in action:
Error: Instance cannot be destroyed
Resource aws_db_instance.ledger has lifecycle.prevent_destroy set.

# create_before_destroy in action:
# aws_instance.transfer must be replaced
+/- resource "aws_instance" "transfer" {
# (create new instance first, then destroy old)
}

__CODE_BLOCK_8__
hcl
# WRONG: Ignoring everything -- Terraform manages nothing
resource "aws_instance" "transfer_service" {
lifecycle {
ignore_changes = all # Terraform will never update this resource!
}
}

# CORRECT: Ignore only the specific external mutation
resource "aws_instance" "transfer_service" {
lifecycle {
ignore_changes = [tags["LastBackup"]] # Only this one tag
}
}

__CODE_BLOCK_9__
bash
# Scenario: renaming the statements bucket in code
# Without state mv: plan shows destroy old, create new (data loss!)
# With state mv: rename in state, plan shows no changes

terraform state mv \
aws_s3_bucket.statements \
aws_s3_bucket.account_statements

terraform plan
# No changes. Your infrastructure matches the configuration.

__CODE_BLOCK_10__
bash
# The controlled change process
terraform plan -out=change.plan # Save the exact plan
terraform show change.plan # Review it
terraform apply change.plan # Apply exactly what was reviewed
```

What are common interview follow-up questions?

  • How do you know if an attribute is immutable? The provider documentation marks it as "ForceNew" or "forces replacement."
  • Can you use prevent_destroy and still delete the resource when you mean to? Yes, remove the prevent_destroy line first, then run apply.
  • What is the difference between create_before_destroy and the default? The default destroys first then creates; create_before_destroy reverses that order so there is no gap.

Visual Overview

DIAGRAM
  Five Layers of Defence Against Unexpected Recreation
  ┌────────────────────────────────────────────────┐
  │  Layer 1: READ THE PLAN                        │
  │  + create  ~ update  -/+ replace  - destroy    │
  ├────────────────────────────────────────────────┤
  │  Layer 2: KNOW IMMUTABLE ATTRIBUTES            │
  │  AZ, engine, CIDR, bucket name = replacement   │
  ├────────────────────────────────────────────────┤
  │  Layer 3: PIN VERSIONS                         │
  │  Provider, module, Terraform core              │
  ├────────────────────────────────────────────────┤
  │  Layer 4: REVIEW IN PRs                        │
  │  Plan attached, second engineer, policy tools  │
  ├────────────────────────────────────────────────┤
  │  Layer 5: LIFECYCLE BLOCKS                     │
  │  prevent_destroy, create_before_destroy,       │
  │  ignore_changes                                │
  └────────────────────────────────────────────────┘

Key Takeaway

Ensuring that a Terraform change does not unexpectedly recreate a resource is about treating infrastructure as a controlled change process with five layers of defence. The first layer is reading the plan properly: Terraform prints plus for create, tilde for update in place, minus for destroy, and minus-plus for replace, and it annotates the exact attribute that forces a replacement. Never trust the one-line summary; read every minus-plus line and ask whether that recreation is acceptable.

The second and third layers are knowing which attributes are immutable and pinning your versions. Things like an EC2 instance's availability zone, an RDS engine, a subnet CIDR, or an S3 bucket name cannot be changed in place, so Terraform's only option is destroy and recreate. Pinning provider and module versions prevents a version bump from silently recomputing a default and turning a no-op into a replacement. The fourth and fifth layers are process and lifecycle rules: every change goes through a PR with the plan attached, and lifecycle blocks like prevent_destroy and create_before_destroy guard stateful resources.

In the National Bank of India project, moving the payments EC2 instance to a new availability zone forced a replacement, but create_before_destroy meant a new instance was running before the old one died, so no in-flight transfer between Rajesh's SAV2001 and Priya's CUR3001 was dropped. Renaming the statements bucket in code would have proposed a destroy, but prevent_destroy refused it and the team used terraform state mv to rename in state instead. When an external backup job kept adding a LastBackup tag, ignore_changes stopped the endless spurious diffs.

Remember one sentence: read the plan, know your immutable attributes, pin your versions, review in PRs, and guard stateful resources with lifecycle blocks: that combination is what prevents an unexpected recreation.

---

Share
Use ← → arrow keys to navigate