Terraform in Production · Changes That Don't Destroy Things

Guard Rails for Destructive Plans

prevent_destroy, deletion protection and an automated plan check together stop a bad plan from deleting stateful resources.

Scenario

Example team: a junior engineer changes the identifier of the orders database to follow a new naming standard. The plan is 600 lines long because of tag updates on dozens of resources. CI applies automatically after one approval.

What you see in production

Buried in the plan: # aws_db_instance.orders must be replaced. The approval comes in, the database is destroyed and a new empty one is created. Restoring from the latest snapshot takes four hours and loses 40 minutes of orders.

The naive code

resource "aws_db_instance" "orders" {
  identifier = "payments-orders-prod"   # was "orders-prod": changing it forces replacement
  # ...
}

and a pipeline that runs terraform apply -auto-approve after a single approval, with nothing reading the plan except a human.

Reproduce it

prevent_destroy makes Terraform refuse any plan that would destroy the resource, including a replacement:

prevent_destroy blocks a forced replacement
resource "terraform_data" "orders_db" {
  triggers_replace = "orders-prod"
  lifecycle {
    prevent_destroy = true
  }
}
# after: rename the database identifier
resource "terraform_data" "orders_db" {
  triggers_replace = "payments-orders-prod"
  lifecycle {
    prevent_destroy = true
  }
}

Output

Error: Instance cannot be destroyed

  on main.tf line 1:
   1: resource "terraform_data" "orders_db" {

Resource terraform_data.orders_db has lifecycle.prevent_destroy set, but the
plan calls for this resource to be destroyed. To avoid this error and
continue with the plan, either disable lifecycle.prevent_destroy or reduce
the scope of the plan using the -target option.

Run this on your computer — see “Get Started” for setup.

But it only protects while the block is in the configuration. Delete the block and the protection goes with it:

Deleting the block removes the protection
resource "terraform_data" "orders_db" {
  triggers_replace = "orders-prod"
  lifecycle {
    prevent_destroy = true
  }
}
# after: the whole block deleted in a cleanup PR

Output

Terraform will perform the following actions:

  # terraform_data.orders_db will be destroyed
  # (because terraform_data.orders_db is not in configuration)
  - resource "terraform_data" "orders_db" {
      - id               = "8f4a0616-33d9-97a7-f53f-15e8d97fc1d4" -> null
      - triggers_replace = "orders-prod" -> null
    }

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

Run this on your computer — see “Get Started” for setup.

Root cause

Replacements hide in long plans, and humans approve diffs they don't read. Every safety net here is partial on its own: prevent_destroy disappears with the block, provider-side deletion protection covers only some resource types, and reviewers get tired. You need layers, including one that reads the plan mechanically.

The fix

Layer the protections:

  1. Cloud-side deletion protection on stateful resources (survives anything Terraform does):
resource "aws_db_instance" "orders" {
  # ...
  deletion_protection       = true
  skip_final_snapshot       = false
  final_snapshot_identifier = "orders-prod-final"
  lifecycle {
    prevent_destroy = true
  }
}
  1. An automated plan check in CI that fails on deletes or replacements of protected types unless the PR carries an explicit label:
CI guard: fail on destructive actions in the plan JSONTry it yourself
import json

# terraform show -json tfplan  (trimmed to the fields the guard reads)
plan = json.loads('''{"resource_changes": [
  {"address": "aws_s3_bucket_policy.logs",  "type": "aws_s3_bucket_policy", "change": {"actions": ["update"]}},
  {"address": "aws_db_instance.orders",     "type": "aws_db_instance",      "change": {"actions": ["delete", "create"]}},
  {"address": "aws_sqs_queue.dead_letter",  "type": "aws_sqs_queue",        "change": {"actions": ["delete"]}},
  {"address": "aws_instance.worker[0]",     "type": "aws_instance",         "change": {"actions": ["create", "delete"]}}
]}''')

PROTECTED = {"aws_db_instance", "aws_rds_cluster", "aws_s3_bucket", "aws_dynamodb_table", "aws_sqs_queue",
             "aws_efs_file_system", "aws_kms_key"}
allow_destroy = False   # set from a PR label such as "allow-destroy"

problems = []
for rc in plan["resource_changes"]:
    actions = rc["change"]["actions"]
    if "delete" not in actions:
        continue
    kind = "replace" if "create" in actions else "delete"
    order = " (create first)" if actions == ["create", "delete"] else ""
    flag = "BLOCK" if rc["type"] in PROTECTED and not allow_destroy else "warn "
    print(f"{flag} {kind:7} {rc['address']}{order}")
    if flag == "BLOCK":
        problems.append(rc["address"])

print(f"{len(problems)} protected resource(s) would be destroyed" if problems else "plan is safe")
raise SystemExit(0)   # in CI: raise SystemExit(1 if problems else 0)

Output

BLOCK replace aws_db_instance.orders
BLOCK delete  aws_sqs_queue.dead_letter
warn  replace aws_instance.worker[0] (create first)
2 protected resource(s) would be destroyed
  1. Apply the reviewed plan file and require approval from an owner of the affected stack, with the destructive-action summary posted as a PR comment so nobody has to find it in 600 lines.

For resources that can be replaced but shouldn't go down while that happens, use create_before_destroy = true (and a name_prefix instead of a fixed name, so old and new can exist at the same time).

Trade-offs

  • Deletion protection makes intentional teardown a two-step change (turn protection off, then delete). That friction is the point for production data.
  • Plan guards need a curated list of protected types and an escape hatch (a label plus a second approver).
  • create_before_destroy needs capacity, quota and unique names for two copies at once.

Detect it in production

  • Post a "destructive actions" summary on every PR, even when CI passes.
  • Alert on delete API calls for databases, buckets and keys (CloudTrail, Azure Activity Log, GCP Audit Logs).
  • Regularly test restores: deletion protection reduces risk, backups recover from what gets through.

Key points

  • Replacements of stateful resources are the most expensive plan line; make them impossible to miss.
  • Layer protections: cloud deletion protection, prevent_destroy, and a CI guard reading terraform show -json.
  • prevent_destroy only works while its block exists; cloud-side protection survives a deleted block.

Practice questions

Does `lifecycle { prevent_destroy = true }` protect a resource if someone deletes its block from the configuration?

Answer

No. The setting lives in the block, so deleting the block removes the protection and the plan simply destroys the resource. Pair it with cloud-side deletion protection and a CI plan guard.

How can CI detect replacements in a plan automatically?

Answer

Save the plan (terraform plan -out=tfplan), convert it with terraform show -json tfplan, and inspect resource_changes[].change.actions: ["delete", "create"] is a replacement (destroy first), ["create", "delete"] is create-before-destroy, and ["delete"] is a destroy.

What does deletion protection on an RDS instance add beyond `prevent_destroy`?

Answer

It is enforced by AWS, so a delete fails no matter which tool or configuration asks for it, including a plan from a configuration where the resource block was removed.

Open in the app to mark lessons complete, take notes and track your progress.