Skip to main content
RunBook Academy

← All labs in Terraform

Lab · intermediate · ~40 min

Lab: lifecycle.prevent_destroy on a Critical Resource

C · Simulation

Objectives

  • Reproduce both plans prevent_destroy rejects: a full destroy, and an edit the provider can only apply by replacing the resource
  • Show that the guard fails the whole plan, so an unrelated change to a neighbouring resource cannot be applied either
  • Prove the documented hole: deleting the resource block deletes the guard with it, and the destroy then applies cleanly
  • Recognise -target and terraform state rm as the two routes that walk past the guard by construction
  • Retire a guarded resource with a removed block and lifecycle destroy = false, leaving the object intact

Prerequisites

Objective

By the end of this lab you will have watched lifecycle.prevent_destroy reject two plans, block a colleague’s unrelated change as collateral, and then fail to stop the one deletion that most resembles how resources actually get deleted in production. You will finish by retiring the guarded resource properly, with a removed block, so that Terraform forgets it and the object survives.

The guard is worth having. The point of the lab is to know exactly where its edge is, because a control you trust past its edge is worse than no control at all.

Architecture

One working directory, one configuration, two resources.

tf-prevent-destroy/
├── main.tf                 both resources, edited across Tasks 5-7
├── ledger.csv              local_file.ledger      — guarded
├── cache-policy.json       local_file.cache_policy — not guarded
└── terraform.tfstate

local_file.ledger carries prevent_destroy = true. Read it as a production RDS instance: expensive to recreate, and the recreation does not bring the rows back. local_file.cache_policy carries no guard at all and stands for the ordinary, rebuildable thing that happens to live in the same state — a security-group rule, a DNS record, a config object someone changes on a Tuesday.

Two resources rather than one is the whole design. Most of what prevent_destroy does wrong in production is not to the resource it guards; it is to whatever else is in the plan.

Requirements

  • A Linux or macOS workstation with shell access and a writable $HOME.
  • Terraform 1.9.x or later. Every output in this lab was captured on Terraform v1.9.8 with hashicorp/local v2.9.0. The removed block in Task 7 needs Terraform 1.7 or later.
  • jq, used once in Task 5 to read the provider schema.
  • Outbound HTTPS to registry.terraform.io for the single terraform init. After that the lab is entirely offline.
  • Roughly 40 minutes, most of it reading plan output rather than typing.
terraform version

Scenario

Your team runs a Terraform estate where every production data store carries prevent_destroy = true. The control was added after an incident and it is written into the platform’s review checklist. Nobody has tested it since.

This quarter the ledger service is being decommissioned. A platform engineer opens a pull request that deletes the local_file.ledger block from main.tf — a clean removal, the resource is going away, the diff is one block. The reviewer sees prevent_destroy = true disappear along with everything else in the block and approves it, reasoning that if the deletion were dangerous the guard would stop the apply.

The apply succeeds. So does the deletion.

Nobody made a mistake with the syntax and nobody bypassed anything. The guard behaved exactly as documented; the team’s mental model of it was wrong in one specific, nameable way. Your job is to find that way by running it.

Tasks

Task 1: Build the configuration

mkdir -p "$HOME/tf-prevent-destroy"
cd "$HOME/tf-prevent-destroy"

Write main.tf:

terraform {
  required_version = ">= 1.9.0"

  required_providers {
    local = {
      source  = "hashicorp/local"
      version = "~> 2.5"
    }
  }
}

# The critical resource. Read this as a production database.
resource "local_file" "ledger" {
  filename = "${path.module}/ledger.csv"
  content  = "id,amount\n1,100\n"

  lifecycle {
    prevent_destroy = true
  }
}

# An ordinary, rebuildable resource in the same configuration.
resource "local_file" "cache_policy" {
  filename = "${path.module}/cache-policy.json"
  content  = "{\"ttl_seconds\":300}\n"
}

Keep a pristine copy. Tasks 5 to 7 edit this file repeatedly, and being able to get back to a known state without retyping matters more than it sounds:

cd "$HOME/tf-prevent-destroy"
cp main.tf main.tf.orig
terraform init

Task 2: Apply the baseline

Configuration changetf-prevent-destroy/
$ terraform apply -auto-approve
local_file.cache_policy: Creating...
local_file.ledger: Creating...
local_file.ledger: Creation complete after 0s [id=699768e6243143f80b905c27ab865337f9ebf5cb]
local_file.cache_policy: Creation complete after 0s [id=07f6a39fc19892d9232c7d928e26fbd0858019f2]

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

Your ids will match the ones printed above, character for character. The local_file id is the SHA-1 of the content, so it is a pure function of what you wrote in main.tf:

printf 'id,amount\n1,100\n' | sha1sum

That determinism is convenient here: if an id in your terminal differs from an id in this lab, you have a typo in main.tf, not a different Terraform.

Confirm the starting state before touching anything:

Read-only / Safetf-prevent-destroy/
$ terraform state list
local_file.cache_policy
local_file.ledger

And confirm the plan is quiet, so that every change you see from here is one you caused:

Read-only / Safetf-prevent-destroy/
$ terraform plan
No changes. Your infrastructure matches the configuration.

Terraform has compared your real infrastructure against your configuration
and found no differences, so no changes are needed.

Task 3: Experiment 1 — the destroy the guard rejects

This is the case prevent_destroy was built for. Predict the outcome, then run it.

Destructivetf-prevent-destroy/
$ terraform destroy -auto-approve
local_file.cache_policy: Refreshing state... [id=07f6a39fc19892d9232c7d928e26fbd0858019f2]
local_file.ledger: Refreshing state... [id=699768e6243143f80b905c27ab865337f9ebf5cb]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy

Terraform planned the following actions, but then encountered a problem:

# local_file.cache_policy will be destroyed
- resource "local_file" "cache_policy" {
    - content              = jsonencode(
          {
            - ttl_seconds = 300
          }
      ) -> null
    - directory_permission = "0777" -> null
    - file_permission      = "0777" -> null
    - filename             = "./cache-policy.json" -> null
    - id                   = "07f6a39fc19892d9232c7d928e26fbd0858019f2" -> null
  }

# local_file.ledger will be destroyed
- resource "local_file" "ledger" {
    - content              = <<-EOT
          id,amount
          1,100
      EOT -> null
    - directory_permission = "0777" -> null
    - file_permission      = "0777" -> null
    - filename             = "./ledger.csv" -> null
    - id                   = "699768e6243143f80b905c27ab865337f9ebf5cb" -> null
  }

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

Error: Instance cannot be destroyed

on main.tf line 13:
13: resource "local_file" "ledger" {

Resource local_file.ledger 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.

The output above is trimmed of the six content_* hash attributes the provider computes, which are noise at this width. Reproduce the trim with terraform destroy -auto-approve 2>&1 | grep -v 'content_' if you want a readable capture for your notes.

Three things to take from this, in order of how often they are missed.

The guard fires at plan time. No provider was asked to delete anything. Terraform computed the plan, found a destroy action on an address whose configuration sets prevent_destroy, and refused to proceed. The command exits non-zero; check with echo $? immediately after and you get 1.

The plan is printed in full first. Everything above the Error: line is an ordinary destroy plan, ending in the familiar Plan: 0 to add, 0 to change, 2 to destroy. summary. An operator who scrolls up, or a CI job that captures only the first N lines of output, sees a normal destroy. The refusal is the last thing on the screen and the only thing that matters.

Both resources survived. Not just the guarded one:

Read-only / Safetf-prevent-destroy/
$ terraform state list
local_file.cache_policy
local_file.ledger

A plan is atomic in the sense that matters here: it either passes validation as a whole or nothing in it is applied. The guard on ledger is what saved cache_policy. Task 5 shows the same property working against you.

Task 4: Experiment 2 — the escape hatch the error hands you

Read the last line of the error again. Terraform names its own bypass: reduce the scope of the plan using the -target option.

Destructivetf-prevent-destroy/
$ terraform destroy -auto-approve -target=local_file.cache_policy
local_file.cache_policy: Refreshing state... [id=07f6a39fc19892d9232c7d928e26fbd0858019f2]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy

Terraform will perform the following actions:

# local_file.cache_policy will be destroyed
- resource "local_file" "cache_policy" {
    - content              = jsonencode(
          {
            - ttl_seconds = 300
          }
      ) -> null
    - directory_permission = "0777" -> null
    - file_permission      = "0777" -> null
    - filename             = "./cache-policy.json" -> null
    - id                   = "07f6a39fc19892d9232c7d928e26fbd0858019f2" -> null
  }

Plan: 0 to add, 0 to change, 1 to destroy.
local_file.cache_policy: Destroying... [id=07f6a39fc19892d9232c7d928e26fbd0858019f2]
local_file.cache_policy: Destruction complete after 0s

Warning: Resource targeting is in effect

You are creating a plan with the -target option, which means that the result
of this plan may not represent all of the changes requested by the current
configuration.

Destroy complete! Resources: 1 destroyed.

Look at the first line. local_file.ledger is not refreshed, not planned, not mentioned. -target did not defeat the guard; it built a plan the guard never saw. That distinction is the useful one — the guard inspects the plan in front of it, so anything that keeps the guarded address out of the plan is, structurally, outside its reach.

Terraform’s own warning is worth reading rather than skipping. It says -target is “not for routine use” and exists for “exceptional situations… or when Terraform specifically suggests to use it as part of an error message”. This is that second case. It is still not routine, and the cost is stated in the second warning: the applied change may be incomplete, so terraform plan afterwards is not optional.

Restore the resource you just destroyed before continuing:

cd "$HOME/tf-prevent-destroy"
terraform apply -auto-approve
terraform plan

The plan is quiet again. cache-policy.json is back, with the same id 07f6a39f…, because the content is unchanged and the id is its hash.

Task 5: Experiment 3 — the change you actually wanted, blocked

Nothing so far has been a surprise. This one usually is.

Two edits, both of them ordinary. Add a row to the ledger, and raise the cache TTL from 300 to 600 seconds — the sort of pair that arrives in one pull request from two people:

resource "local_file" "ledger" {
  filename = "${path.module}/ledger.csv"
  content  = "id,amount\n1,100\n2,250\n"

  lifecycle {
    prevent_destroy = true
  }
}

resource "local_file" "cache_policy" {
  filename = "${path.module}/cache-policy.json"
  content  = "{\"ttl_seconds\":600}\n"
}

Predict the plan before you run it. Neither edit asks for a destroy.

Read-only / Safetf-prevent-destroy/
$ terraform plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
-/+ destroy and then create replacement

Terraform planned the following actions, but then encountered a problem:

# local_file.cache_policy must be replaced
-/+ resource "local_file" "cache_policy" {
    ~ content              = jsonencode(
        ~ {
            ~ ttl_seconds = 300 -> 600
          } # forces replacement
      )
    ~ id                   = "07f6a39fc19892d9232c7d928e26fbd0858019f2" -> (known after apply)
      # (3 unchanged attributes hidden)
  }

# local_file.ledger must be replaced
-/+ resource "local_file" "ledger" {
    ~ content              = <<-EOT # forces replacement
          id,amount
          1,100
        + 2,250
      EOT
    ~ id                   = "699768e6243143f80b905c27ab865337f9ebf5cb" -> (known after apply)
      # (3 unchanged attributes hidden)
  }

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

Error: Instance cannot be destroyed

on main.tf line 13:
13: resource "local_file" "ledger" {

Resource local_file.ledger 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.

Both resources plan as -/+, destroy-then-create. Nobody asked for that. The # forces replacement comment is the provider saying it has no Update for content: the only way to change a file’s contents is to write a new file, which for Terraform is a destroy followed by a create. prevent_destroy sees a destroy on local_file.ledger and rejects the plan, exactly as designed — this is the “safety against accidental replacement” case from the prevent_destroy documentation, and it is also failure mode 2 from terraform destroy: A Production-Dangerous Operation, where an apply fails pointing at a resource the operator was not trying to touch.

Now the part that costs a change window. The TTL change is unaffected by any of this, and it cannot be applied either. One resource’s lifecycle guard has failed a plan containing somebody else’s work.

Back the ledger edit out, keep the TTL change, and apply. This is what the unblocking commit looks like in practice: the guarded resource is returned to exactly what state already holds, and the innocent change goes through on its own.

Configuration changetf-prevent-destroy/
$ terraform apply -auto-approve
Plan: 1 to add, 0 to change, 1 to destroy.
local_file.cache_policy: Destroying... [id=07f6a39fc19892d9232c7d928e26fbd0858019f2]
local_file.cache_policy: Destruction complete after 0s
local_file.cache_policy: Creating...
local_file.cache_policy: Creation complete after 0s [id=20f82c0b903b56096933a9b84b6d025ad31f755e]

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

Task 6: Experiment 4 — delete the block, and the guard goes with it

This is the scenario from the top of the lab. The ledger service is decommissioned, so the engineer deletes the resource block. Delete it from main.tf — the whole resource "local_file" "ledger" block, lifecycle and all — leaving the terraform block and local_file.cache_policy in place.

Predict the plan. Everything you have seen so far says the guard rejects destroys of this address.

Read-only / Safetf-prevent-destroy/
$ terraform plan
local_file.cache_policy: Refreshing state... [id=20f82c0b903b56096933a9b84b6d025ad31f755e]
local_file.ledger: Refreshing state... [id=699768e6243143f80b905c27ab865337f9ebf5cb]

Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
- destroy

Terraform will perform the following actions:

# local_file.ledger will be destroyed
# (because local_file.ledger is not in configuration)
- resource "local_file" "ledger" {
    - content              = <<-EOT
          id,amount
          1,100
      EOT -> null
    - directory_permission = "0777" -> null
    - file_permission      = "0777" -> null
    - filename             = "./ledger.csv" -> null
    - id                   = "699768e6243143f80b905c27ab865337f9ebf5cb" -> null
  }

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

No error. The plan exits 0. Terraform will perform the following actions rather than planned the following actions, but then encountered a problem.

The reason is stated plainly in the second comment line and in the prevent_destroy documentation: the setting must be present in configuration for the protection to apply, and deleting the resource block deletes the setting along with it. There is no copy of the guard in state. Terraform is not overriding anything — at the moment it builds this plan, nothing in the configuration says this resource is protected.

Apply it. This destroys real data, which is the point of doing it on a file you can afford to lose:

Data-loss risktf-prevent-destroy/
$ terraform apply -auto-approve
Plan: 0 to add, 0 to change, 1 to destroy.
local_file.ledger: Destroying... [id=699768e6243143f80b905c27ab865337f9ebf5cb]
local_file.ledger: Destruction complete after 0s

Apply complete! Resources: 0 added, 0 changed, 1 destroyed.
cd "$HOME/tf-prevent-destroy"
ls ledger.csv
terraform state list

ls reports that the file does not exist. terraform state list returns local_file.cache_policy alone. The guarded resource is gone, from disk and from state, and every Terraform command in this task exited 0.

There is a second route past the guard, and it is worth knowing so you recognise it in a shell history rather than discovering it. terraform state rm local_file.ledger succeeds against a guarded resource — state operations do not evaluate lifecycle — and prints:

Removed local_file.ledger
Successfully removed 1 resource instance(s).

That command destroys nothing. It orphans: the object stays and Terraform forgets it, so while the resource block is still in the configuration the next plan proposes to build it again — for a cloud resource that means a second live object beside the one you detached, still billing; for a file it means overwriting what is on disk. Do not run it here. local_file implements no import, so there is no way to re-adopt what you detached, and getting back on the lab’s path would mean deleting the file by hand and re-applying. Task 7 is the supported version of the same intent.

Task 7: Retire the resource without destroying it

The engineer’s real goal was never “delete the ledger”. It was “stop managing the ledger with this configuration” — the service is decommissioned, the data goes to the archive team, the file stays. That is a removed block, available since Terraform 1.7.

Put the ledger back first, so you have something to retire. Re-add the resource "local_file" "ledger" block to main.tf exactly as Task 1 wrote it, and leave cache_policy on ttl_seconds = 600 — restoring main.tf.orig wholesale would revert the TTL too, and replace a resource this task is not about.

cd "$HOME/tf-prevent-destroy"
terraform apply -auto-approve
cat ledger.csv

The file returns with the same id, 699768e6…, because the content is the same. Now do the removal properly. Delete the resource "local_file" "ledger" block again, and this time add a removed block in its place:

# Retire the ledger from Terraform's control without deleting the object.
removed {
  from = local_file.ledger

  lifecycle {
    destroy = false
  }
}
Read-only / Safetf-prevent-destroy/
$ terraform plan
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:

Terraform will perform the following actions:

# local_file.ledger will no longer be managed by Terraform, but will not be destroyed
# (destroy = false is set in the configuration)
. resource "local_file" "ledger" {
      id                   = "699768e6243143f80b905c27ab865337f9ebf5cb"
      # (10 unchanged attributes hidden)
  }

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

Warning: Some objects will no longer be managed by Terraform

If you apply this plan, Terraform will discard its tracking information for
the following objects, but it will not delete them:
- local_file.ledger

Compare this plan with Task 6’s, side by side in your results note. Same intent, same file, same guard — and this one reads 0 to add, 0 to change, 0 to destroy with a symbol most operators have never seen: ., meaning “forget, do not touch”.

Apply it, then confirm the object outlived its own removal:

cd "$HOME/tf-prevent-destroy"
terraform apply -auto-approve
terraform state list
cat ledger.csv

terraform state list returns local_file.cache_policy only. ledger.csv still holds its two lines.

Validation

Work through these in order. Each is a command with a stated result, not a feeling that the lab went well.

  1. terraform state list returns exactly local_file.cache_policy.
  2. cat ledger.csv prints id,amount and 1,100. The file survived being removed from Terraform’s control in Task 7.
  3. cat cache-policy.json prints {"ttl_seconds":600} — the TTL change from Task 5 that the guard blocked on its first attempt.
  4. terraform plan reports No changes. Your infrastructure matches the configuration.
  5. Your results note records that Experiments 1 and 3 were rejected by the guard, and that Experiments 2 and 4 were not.
  6. You can state, without looking it up, why the plan in Task 6 exited 0 while the plan in Task 5 exited 1, even though both proposed to destroy local_file.ledger.
  7. You can name the one-line difference between a removed block that retires a resource and a removed block that deletes it.

Expected Outcome

tf-prevent-destroy/
├── .terraform.lock.hcl
├── main.tf                 cache_policy + a removed block for the ledger
├── main.tf.orig            the pristine two-resource configuration
├── cache-policy.json       managed, ttl_seconds = 600
├── ledger.csv              unmanaged, contents intact
├── results.md              your four predictions and four observations
├── terraform.tfstate       one address
└── terraform.tfstate.backup

One resource under management, one object deliberately outside it, and a note recording which experiments the guard stopped. The state and the directory disagree about ledger.csv on purpose: that is what “retired, not destroyed” looks like on disk.

Troubleshooting

The plan in Task 3 succeeded instead of failing. The lifecycle block is not where you think it is. It must sit inside the resource "local_file" "ledger" block. Run grep -B6 -A4 lifecycle main.tf and check which resource it is nested in. A lifecycle block at the top level is a configuration error Terraform reports out loud; a lifecycle block on the wrong resource is perfectly valid HCL and silently guards nothing, which is the version that reaches production.

Error: Unsupported block type on the removed block. Your Terraform predates 1.7. Check terraform version. There is no pre-1.7 way to express this in configuration; the older approach is terraform state rm, which reaches the same end state through a state operation nobody reviews rather than through a plan somebody does.

The removed block destroyed the file. lifecycle { destroy = false } was missing or misspelled. The object is gone and, for local_file, unrecoverable — the provider implements no import, so it cannot be re-adopted. Recover by re-adding the ledger block and applying, which creates a fresh file with the same content and therefore the same id — note that a real data store does not come back that way.

Error: Resource Import Not Implemented. You tried to import local_file.ledger back after Task 7, probably because Terraform’s own warning suggests importing to manage the object again. That advice is generic; this provider does not support it. It is a limitation of hashicorp/local, not of the removed block — aws_db_instance imports cleanly.

terraform plan wants to recreate ledger.csv after Task 7. The removed block is gone from main.tf and the resource block is back, so Terraform sees a configured resource with no state entry. That is the orphan case: applying gives you a second, Terraform-managed file that overwrites the unmanaged one. Decide deliberately which you want before applying.

Error: Invalid legacy provider address or a lock-file complaint on first init. A .terraform directory left over from another lab is in the way. Remove .terraform and .terraform.lock.hcl from the lab directory and re-run terraform init.

Cleanup

Everything this lab created lives under one directory. No system configuration was changed, no service was contacted, no privilege was escalated.

Step 1. Keep the results note — it is the deliverable, and the only part of this lab that is yours:

mkdir -p "$HOME/terraform-lab-deliverables"
cp "$HOME/tf-prevent-destroy/results.md" \
   "$HOME/terraform-lab-deliverables/prevent-destroy-results.md"

Step 2. Destroy what Terraform still manages. This is one resource, not two — the ledger was retired from state in Task 7, so terraform destroy will not touch ledger.csv and you should not expect it to:

Destructivetf-prevent-destroy/
$ terraform -chdir="$HOME/tf-prevent-destroy" destroy -auto-approve
Plan: 0 to add, 0 to change, 1 to destroy.
local_file.cache_policy: Destroying... [id=20f82c0b903b56096933a9b84b6d025ad31f755e]
local_file.cache_policy: Destruction complete after 0s

Destroy complete! Resources: 1 destroyed.

ledger.csv is still on disk after this, and that is correct. An orphaned object needs a deliberate, separate deletion — which is exactly the position a real retired database is in, and exactly why retiring one belongs in a ticket with an owner.

Step 3. Confirm what you are about to delete, then delete it.

ls -la "$HOME/tf-prevent-destroy"
Destructiveworkstation
$ rm -rf "$HOME/tf-prevent-destroy"

Production notes

Map each experiment onto the change window it belongs to.

Experiment 1, the rejected destroy, is not a change window at all. It is the control working, at plan time, in CI, before anyone is paged. Wire it into the pipeline as a required check on every branch and the guard does its job with no human in the loop.

Experiment 2, the -target escape, belongs to an incident, not a change window. Terraform suggests it in the error precisely because there are moments when narrowing the plan is the correct move — usually recovering from a half-applied state at an hour when the full plan is not something you want to reason about. The obligation it creates is a terraform plan afterwards, before the incident is closed, because a targeted apply leaves the estate in a condition the configuration has not been checked against. A -target in a routine pipeline is a different thing entirely and should fail review on sight.

Experiment 3, the blocked edit, is a change window that will overrun. The plan fails for a reason unrelated to the change being deployed, and the operator’s first instinct — remove the guard, apply, put the guard back — turns a routine deployment into an unreviewed edit of a data-store lifecycle block at the worst possible moment. Two habits prevent that: keep guarded resources in a state whose plans are small, and treat any change to a lifecycle block as its own pull request with its own reviewer, never as a step inside another change.

Experiment 4, the removal, is the change window that needs the ticket. Decommissioning a guarded resource is a planned operation with an owner and an end time. The plan is the artefact under review, not the diff: terraform plan -out=tfplan in the pull request, terraform show tfplan in the ticket, and a reviewer whose specific job is to check that the addresses in the destroy list are the addresses the ticket names. The guard cannot do this for you, and a review process that assumes it can is the failure this lab reproduces.

“Hold” is a first-class outcome here. If the plan shows a destroy you cannot fully account for, the correct action is to stop and leave the estate as it is. A blocked deployment costs an afternoon; unplanned destruction of a data store costs whatever the restore costs, plus the window in which the service is down, plus the possibility that the restore does not exist. Name the person who owns the hold and the time the decision gets revisited, then walk away from the terminal.

What You Learned

  • prevent_destroy rejects plans, not operations. The check runs at plan time against the destroy actions Terraform computed. Nothing reaches a provider. That is why it is cheap enough to leave on permanently, and why an unrelated -target plan slips past it without bypassing anything.
  • It fails the whole plan. A guarded resource with a destroy in the plan blocks every other change in that plan. The control’s blast radius is the state, and the fix for that is a state boundary, not a lifecycle argument.
  • It blocks replacements, which is usually what you meet first. For a provider with no Update for the attribute you edited, the change you wanted is a destroy-then-create, and the guard treats it as the destroy it is. # forces replacement in the plan is the line that explains the error.
  • Deleting the resource block deletes the guard. The setting lives in configuration and nowhere else. The single most common way a resource actually gets removed is the one case prevent_destroy structurally cannot see, and you have now watched that apply exit 0.
  • terraform state rm walks past it too, because state operations never evaluate lifecycle. It orphans rather than destroys, which is a different failure with a longer tail.
  • removed with lifecycle { destroy = false } is the supported way to retire a guarded resource. Terraform forgets the object and does not touch it. Without that one line, the same block deletes it.

Deliverables

  • · A configuration with one guarded and one unguarded resource, and the rejected plans captured from it
  • · A written note recording, for each of the four experiments, whether the guard stopped it
  • · A removed block that retires the guarded resource from state with the object still on disk

Verification status

Last reviewed
2026-08-19
Executed end to end
2026-08-19