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
removedblock 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.iofor the singleterraform 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
$ terraform apply -auto-approvelocal_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:
$ terraform state listlocal_file.cache_policy
local_file.ledgerAnd confirm the plan is quiet, so that every change you see from here is one you caused:
$ terraform planNo 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.
$ terraform destroy -auto-approvelocal_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:
$ terraform state listlocal_file.cache_policy
local_file.ledgerA 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.
$ terraform destroy -auto-approve -target=local_file.cache_policylocal_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.
$ terraform planTerraform 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.
$ terraform apply -auto-approvePlan: 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.
$ terraform planlocal_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:
$ terraform apply -auto-approvePlan: 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
}
}
$ terraform planTerraform 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.ledgerCompare 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.
terraform state listreturns exactlylocal_file.cache_policy.cat ledger.csvprintsid,amountand1,100. The file survived being removed from Terraform’s control in Task 7.cat cache-policy.jsonprints{"ttl_seconds":600}— the TTL change from Task 5 that the guard blocked on its first attempt.terraform planreportsNo changes. Your infrastructure matches the configuration.- Your results note records that Experiments 1 and 3 were rejected by the guard, and that Experiments 2 and 4 were not.
- You can state, without looking it up, why the plan in Task 6 exited
0while the plan in Task 5 exited1, even though both proposed to destroylocal_file.ledger. - You can name the one-line difference between a
removedblock that retires a resource and aremovedblock 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:
$ terraform -chdir="$HOME/tf-prevent-destroy" destroy -auto-approvePlan: 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"
$ 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_destroyrejects 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-targetplan 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
Updatefor the attribute you edited, the change you wanted is a destroy-then-create, and the guard treats it as the destroy it is.# forces replacementin 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_destroystructurally cannot see, and you have now watched that apply exit0. terraform state rmwalks past it too, because state operations never evaluatelifecycle. It orphans rather than destroys, which is a different failure with a longer tail.removedwithlifecycle { 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.