All posts
Infrastructure as Code

How Do You Catch a Destructive Terraform Plan Before It Runs?

DevLift Engineering7 min read

Two Terraform plans. One adds a tag to an S3 bucket. One replaces your production database.

In the terminal they look nearly identical: a wall of green and yellow lines, a summary at the bottom, a prompt asking if you want to continue. The difference is a single word buried a few hundred lines up, and the summary line at the bottom describes both of them as changes.

That is the entire problem. Not that people are careless, but that the most dangerous thing a plan can do is presented at the same visual weight as the least.

What counts as destructive?

More than "destroy", which is the part that catches people out.

Terraform's plan JSON gives each resource an actions array. The values that matter:

  • ["create"] is safe.
  • ["update"] is an in-place change. Usually safe, occasionally not, depending on the attribute.
  • ["delete"] is a removal. Obviously destructive.
  • ["delete", "create"] is a replace. The resource is destroyed and a new one built. This is the dangerous one, because a replacement of an RDS instance or an EBS volume is data loss wearing the word "change".
  • ["create", "delete"] is also a replace, with create_before_destroy set. Safer for availability, still a new resource.
  • ["no-op"] and ["read"] are noise.

The rule that covers all of these: treat delete appearing anywhere in the actions array as destructive. Not equality against ["delete"], which misses both replace forms and is the bug most hand-rolled checks ship with.

Why does the plan output hide this?

Because the summary counts by intent, not by consequence.

Plan: 2 to add, 3 to change, 0 to destroy is reassuring and can be entirely false comfort. A replacement is counted where you would expect, but people read the destroy number, see zero, and stop. Worse, a large plan pushes the marker for an individual resource far up the scroll, while the reassuring summary sits at the bottom where the eye lands.

Colour does not save you either, because a routine plan is already colourful. There is no visual budget left for the one line that matters.

How do you detect it automatically?

Do not parse the terminal output. It is formatted for humans, it changes between versions, and colour codes make it worse.

Save the plan and convert it:

terraform plan -out=tfplan
terraform show -json tfplan > plan.json

The JSON has a documented, versioned structure. The part you want is resource_changes, an array with one entry per resource, each carrying an address and a change.actions array.

Pull out anything with a delete:

jq -r '
  .resource_changes[]
  | select(.change.actions | index("delete"))
  | "\(.change.actions | join("+"))  \(.address)"
' plan.json

Which gives you exactly the list a reviewer needs:

delete         aws_s3_bucket.old_logs
delete+create  aws_db_instance.primary

The second line is the one worth stopping for, and it is the one the summary called a change.

To turn that into a pipeline gate, count instead of listing:

DESTROYS=$(jq '[.resource_changes[]
  | select(.change.actions | index("delete"))] | length' plan.json)

if [ "$DESTROYS" -gt 0 ]; then
  echo "::warning::plan destroys or replaces $DESTROYS resource(s)"
fi

Roughly ten lines, no dependencies beyond jq, and it works the same on every Terraform and OpenTofu version that emits this format.

Should the pipeline fail or just flag?

Failing outright is the wrong default, and it is where most teams get this wrong on the first attempt.

Deletion is a legitimate operation. Decommissioning a service means deleting things. A pipeline that hard-fails on any destroy trains people to bypass it, and a bypassed check is worse than no check because it produces false confidence in everyone who has not learned the workaround yet.

A better ladder, in increasing severity:

Annotate always. Put the destroy list at the top of the plan comment, above the summary. Most of the value is here and it costs nothing. A reviewer who can see delete+create aws_db_instance.primary in the first line of the comment does not need a gate.

Escalate approval when the count is above zero. A plan with destroys needs a different approver, or two, or an explicit acknowledgement. This is where the check earns its keep: it turns one uniform review process into two, weighted by actual risk. It also connects directly to where the human gate belongs.

Block by resource type. Some things should never be destroyed by a routine pipeline: databases, state buckets, VPCs, anything holding data. Match on the address prefix and require a deliberate override. This is narrow enough to stay credible, which is what keeps it from being routed around.

Never block everything. The check is there to make risk visible, not to make deletion impossible.

What about false positives?

There are fewer than you expect, and the ones that exist are informative.

The common surprise is a resource that replaces on a change you thought was in-place. Someone edits an attribute that Terraform marks ForceNew, and a small edit becomes a replacement. That is not a false positive. That is the check doing precisely its job, and it is the single most valuable thing it catches.

The genuine noise comes from resources that are cheap to replace and get replaced constantly: null resources, local files, random IDs, some IAM policy attachments. Allowlist those by type rather than loosening the rule. An allowlist is auditable; a weakened condition is invisible.

Can you just use prevent_destroy?

Partly, and it is worth having, but it does not replace the check above.

lifecycle {
  prevent_destroy = true
}

Put it on the resources that should never go: the database, the state bucket, the VPC. When a plan would destroy one, Terraform errors.

Three limitations decide where it fits.

It errors during plan, not before review. In a pipeline that plans on the pull request, that is fine. In one that plans at apply time, you find out after a human already approved, which is the most expensive possible moment.

It is per resource, declared in advance. It protects what you remembered to annotate. The JSON check covers everything, including the resource added last week by someone who did not know the convention.

It is removable in the same commit. Deleting the lifecycle block and the resource is one change, and a reviewer skimming a large diff will not necessarily notice the four lines that came out.

Use both. prevent_destroy as a backstop on the crown jewels, the plan check as the thing that makes every destroy visible to a person before they approve.

How is this different from drift detection?

They sound similar and answer opposite questions.

Drift detection asks: has reality diverged from the code? It runs on a schedule, against no proposed change, and finds things that were altered outside Terraform.

Destructive plan detection asks: is this proposed change going to remove something? It runs per change, before apply, on a plan that does not exist yet in reality.

You want both, and they fail differently. Drift finds the console edit somebody made at 2am. This finds the pull request that was going to replace a database on Tuesday afternoon.

Where should the check live?

In the pipeline, next to the plan, in the same job that produces it.

Not as a local pre-commit hook, because it needs a real plan against real state and that is not something you want every laptop doing. Not as a separate scheduled job, because by then the plan is stale.

The sequence that works:

  1. terraform plan -out=tfplan
  2. terraform show -json tfplan > plan.json
  3. Extract the destroy list
  4. Post it at the top of the review comment, above the summary
  5. Set the approval requirement from the count
  6. Apply the saved plan file, not a fresh one

Step six matters and is easy to skip. If you apply by re-planning rather than applying tfplan, the thing that runs is not the thing that was reviewed. Everything above is wasted if the artefact changes between review and apply.

The short version

terraform show -json, look for delete anywhere in the actions array, put the result where the reviewer cannot miss it.

Ten lines of jq. The reason it is worth writing is not that it is clever, but that the alternative is asking a person to spot one word in four hundred lines, reliably, at the end of the day, forever.


DevLift generates infrastructure changes as a pull request with the plan attached, so what gets reviewed is what gets applied. Book a walkthrough, or read where the human gate belongs.

See what this looks like on your own cloud account

DevLift's agents run continuous cost, drift and compliance detection across AWS, Azure and GCP, and propose fixes as reviewable changes, not dashboards. A walkthrough takes 30 minutes.

Schedule a demo

Keep reading