Terraform Says It Will Destroy Something It Should Not
A plan showing forced replacement of a database is a good moment to stop. Here is how to read why, and the three ways state gets out of sync.
The short answer
# aws_db_instance.main must be replaced
-/+ resource "aws_db_instance" "main" {
~ availability_zone = "us-east-1a" -> "us-east-1b" # forces replacement
Terraform found a difference between your configuration and its state, in an attribute the provider marks as requiring replacement. It cannot change that attribute in place, so it plans to destroy and recreate.
Read the # forces replacement comments. There will be one or more, and they name the exact attribute.
Before doing anything:
terraform plan -out=tfplan
terraform show -json tfplan | jq '.resource_changes[] | select(.change.actions[] | contains("delete"))'
Then add lifecycle protection to anything stateful so this cannot be applied by accident:
lifecycle {
prevent_destroy = true
}
Tested on Terraform 1.10.
Read the plan properly
The symbols matter and people skim them.
| Symbol | Meaning |
|---|---|
+ |
Create |
- |
Destroy |
~ |
Update in place, safe |
-/+ |
Destroy then create, data loss |
+/- |
Create then destroy, with create_before_destroy |
<= |
Read a data source |
-/+ on anything holding data is the one to stop at. For a stateless service it is routine. For an RDS instance, an EBS volume, or a stateful set, it means downtime and possibly data loss.
Get the summary rather than reading 900 lines:
terraform show -json tfplan \
| jq -r '.resource_changes[] | "\(.change.actions | join(",")) \(.address)"' \
| sort | uniq -c
That gives you a count by action in one line, and it is the first thing I run on any plan touching production.
Why a replacement is forced
Providers mark certain attributes as ForceNew. Changing them cannot be done in place because the underlying API has no update operation for them.
Common ones:
- Availability zone or subnet on most compute resources
nameon many resources, since the name is the identity- Engine version downgrades on RDS
- Encryption settings once set
- The instance type on some resource types
- Anything in a
locationorregionfield
The # forces replacement comment appears on the specific line. If several attributes changed, only some will carry it, and finding which is the whole diagnosis.
The three ways this happens unintentionally
1. Someone changed it in the console
The most common. A person fixed something at 2am through the web console and never told anyone. Terraform's state says one thing, reality says another, and the next plan wants to reconcile.
Detect it:
terraform plan -refresh-only
That shows drift between state and reality without proposing any configuration changes. Worth running on a schedule, because knowing about drift before it appears inside an urgent plan is a large quality of life improvement.
If the manual change was correct, bring it into configuration and then:
terraform apply -refresh-only
which updates state to match reality without touching infrastructure.
2. A provider upgrade changed a default
You bumped the AWS provider from 5.x to 6.x and an attribute's default changed. Your configuration does not set it explicitly, so Terraform now sees a difference.
This is why provider versions should be pinned with a lockfile committed:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.80"
}
}
}
And .terraform.lock.hcl in git. Upgrading providers should be a deliberate change with its own plan reviewed, not something that happens because CI ran terraform init on a fresh machine.
3. A resource was recreated outside Terraform
Someone deleted and recreated a resource manually. The new one has a different id. Terraform's state points at the old id, finds nothing, and plans to create.
Fix by importing rather than applying:
terraform import aws_db_instance.main mydb-prod-1
Or in modern Terraform, declaratively, which is much better because it goes through review:
import {
to = aws_db_instance.main
id = "mydb-prod-1"
}
Then terraform plan shows what importing would do, and applying performs it. The declarative form is reviewable and repeatable, which the CLI command is not.
Renames and moves
A large share of accidental destroy plans are just renames.
# before
resource "aws_s3_bucket" "assets" { ... }
# after
resource "aws_s3_bucket" "static_assets" { ... }
Terraform tracks resources by address. Renaming the block means the old address disappears and a new one appears, so the plan is destroy plus create. Same bucket, different label, and Terraform has no way to know they are the same thing.
Tell it:
moved {
from = aws_s3_bucket.assets
to = aws_s3_bucket.static_assets
}
The moved block is checked into your configuration, works in CI, and is reviewable. It replaced terraform state mv for most purposes and it is considerably safer, because a state mv is an unreviewed imperative action against production state.
Same mechanism for moving a resource into a module, or changing a count to a for_each, which otherwise re-indexes everything and produces a plan that wants to destroy your entire fleet.
That count to for_each migration is worth calling out. With count, resources are addressed by index, so removing the second item in a list shifts everything after it and Terraform plans to destroy and recreate all of them. With for_each they are addressed by key and removing one affects only that one. Prefer for_each over count for anything where the collection can change, which is nearly always.
Protecting yourself
prevent_destroy on anything stateful.
resource "aws_db_instance" "main" {
lifecycle {
prevent_destroy = true
}
}
The plan now fails rather than proceeding. Removing the flag is a visible diff somebody has to approve.
create_before_destroy for things that must not have a gap:
lifecycle {
create_before_destroy = true
}
Requires that the name is unique or generated, since both exist briefly.
ignore_changes for attributes managed elsewhere:
lifecycle {
ignore_changes = [
desired_count, # managed by autoscaling
tags["LastScanned"] # written by a security tool
]
}
Use sparingly. Every ignored attribute is a place where your configuration no longer describes reality, and it will confuse the next person.
Always plan -out and apply that file. Applying a plan file guarantees you apply what you reviewed. A bare terraform apply re-plans, and reality may have changed in the intervening minutes.
Require plan output in the pull request. Atlantis, Terraform Cloud, or a CI job that comments the plan. The plan is the artifact that should be reviewed, not the diff of the HCL, because the same HCL change can produce very different plans against different states.
When state is genuinely broken
Occasionally state and reality diverge past what refresh can fix.
Always back up state first.
terraform state pull > backup-$(date +%s).tfstate
Then targeted surgery:
terraform state list
terraform state show aws_db_instance.main
terraform state rm aws_db_instance.main # forget it, does not delete anything
state rm removes the resource from Terraform's tracking without touching the real infrastructure. Combined with import, that is how you re-point state at the correct object.
If you use remote state with locking, and a run was interrupted, you may need to release the lock:
terraform force-unlock <lock-id>
Only after confirming no apply is actually running. Force unlocking during a live apply is how you get two applies against the same state and genuinely corrupt it.
The habit
The general principle here is the same one that applies to any destructive operation: make the dangerous thing require an extra deliberate step.
prevent_destroy on stateful resources, plan files reviewed rather than applied blind, moved blocks instead of state surgery, and provider versions pinned. None of it is sophisticated. It is the difference between a plan that fails safely and an apply that removes a database.