Hands-on lab
Lab: The Plan That Would Have Deleted Production
A routine-looking Terraform pull request. Find the changes that would destroy data before you approve it, and work out which one is not what it appears to be.
Scenario
A pull request adds tagging standards across the estate. The diff is small and looks harmless. The plan is 340 lines. You are the approver, and two of the changes in it would cause an outage.
What you will practise
- Read a plan mechanically rather than by scanning terminal output
- Distinguish an in-place update from a replacement
- Identify which attribute forced a replacement, and why
- Decide what a rollback would and would not restore
The situation
A pull request titled “Apply tagging standard to all resources” adds a tags block to a shared
module. The diff is eleven lines. Three people have already approved it.
You are the fourth reviewer and you have the plan output. It is 340 lines and covers 47 resources.
The plan summary
That line is the one that should stop you. A tagging change should be 0 to add, N to change, 0 to destroy.
What you have
Rather than scrolling the output, you query it:
terraform show -json tfplan > plan.json
jq -r '
.resource_changes[]
| select(.change.actions != ["no-op"])
| "\(.change.actions | join("+"))\t\(.address)"
' plan.json | sort
Two resources will be destroyed and recreated. Terraform counted them as “2 to destroy” — which is accurate, and which reads like something small when it is at the end of a 340-line plan.
The detail
Stop and work it
- Which resource loses data, and which does not?
- Neither replacement is caused by the tagging change. What actually caused each one?
- If you applied this and it went wrong, what would rolling back restore?
Working it through
The database
aws_db_instance.orders is being replaced because db_subnet_group_name changed from
prod-db-subnets to prod-db-subnet-group. That attribute forces replacement.
Replacement of an RDS instance means: the existing instance is destroyed, and a new empty one is
created. The data is gone, unless a final snapshot is taken and manually restored — and
skip_final_snapshot settings vary.
The endpoint also changes. Every application holding the old endpoint would fail even after a restore.
The cache
aws_elasticache_cluster.sessions is being replaced because cluster_id changed. Also a
replacement — but the contents are sessions, which are ephemeral by design.
The impact is different in kind: no permanent data is lost, but every logged-in user is signed out at once, and the resulting re-authentication surge hits your identity provider and database simultaneously.
Serious, but recoverable. Not the same category as the database.
What actually caused them
Neither is caused by the tagging change. Both are drift being corrected.
Someone renamed the subnet group and the cache cluster in the console, at some point in the past. Terraform state still holds the old names, and nobody has run a plan against this module since — because nobody had reason to.
The tagging change is the first plan in weeks. It did not cause these; it revealed them.
That is worth sitting with. The pull request is innocent. The dangerous changes were already waiting in state, and would have been triggered by whichever plan ran next.
What a rollback would restore
Almost nothing that matters:
- The database: reverting the code and applying creates another new empty instance. The data is not restored by Terraform. Recovery requires a snapshot restore, and the endpoint would change again.
- The cache: recreated empty. Sessions are gone either way.
- Anything holding the old endpoints: still broken until reconfigured.
The rollback plan for this apply is a restore plan, and it would have to have been written and tested before the apply — not improvised afterwards.
What should happen
Do not approve. The pull request is fine; the plan is not.
Then, in order:
-
Reconcile the drift separately, and deliberately. Update the configuration to match the real names, so Terraform stops wanting to change them:
db_subnet_group_name = "prod-db-subnet-group" # matches realityNow the plan shows no change for that attribute, because there is no longer a difference.
-
Add guards to anything holding data:
resource "aws_db_instance" "orders" { # ... lifecycle { prevent_destroy = true } }With this in place, the plan would have failed rather than proposing a destroy. That is the correct behaviour: an error at plan time instead of an outage at apply time.
-
Then apply the tagging change, which should now be
0 to add, 43 to change, 0 to destroy. -
Add drift detection, so the next occurrence is a scheduled finding rather than a surprise during an urgent change:
terraform plan -refresh-only -detailed-exitcode # exit 2 means drift; alert on it
Prevention
# In CI: fail the check if the plan contains any destroy
DESTROYS=$(jq -r '
[.resource_changes[] | select(.change.actions | index("delete"))] | length
' plan.json)
if [ "$DESTROYS" -gt 0 ]; then
echo "Plan contains $DESTROYS destructive change(s). Requires explicit approval."
jq -r '.resource_changes[] | select(.change.actions | index("delete")) | .address' plan.json
exit 1
fi
This turns “nobody noticed line 287” into “somebody deliberately approved a destroy”. It is a dozen lines of CI and it is the highest-value control in Terraform operations.
The Terraform Plan Analyzer does the same analysis interactively, and escalates destroys of stateful and access-controlling resource types.
What to take away
Read the summary line first. N to destroy on a change that should destroy nothing is the
whole review, and it takes one second.
Never scan a large plan visually. -/+ and ~ are two characters apart in scrolling output.
Query the JSON.
# forces replacement is the most important comment in Terraform. Find those before reading
anything else.
A plan reflects everything that has drifted, not just what you changed. The first plan after a quiet period carries every accumulated difference, and the person who happens to run it inherits all of them.
prevent_destroy converts an outage into an error message. Put it on everything that holds
data you cannot recreate.
Verification status
This resource has not been executed end to end in a lab environment. Commands and configuration are reviewed by an engineer, but treat them as reference rather than as a tested procedure.
Author
James Joyner
Builds and operates the infrastructure layers underneath production AI systems.
James founded Inside The AI Stack to publish the kind of infrastructure and operations material he wanted while running production systems: specific, tested where it claims to be tested, and written by someone who has had to fix the thing at 3am. He works across AI infrastructure, private cloud, and platform engineering, and reviews every technical resource published here before it is marked as verified.
- AI infrastructure
- OpenStack operations
- Kubernetes
- Terraform
- Linux systems engineering
- Observability
Continue from here
Related resources chosen because they are the next thing you would actually need — not because they share a keyword.
Terraform Production Practices
Running Terraform against production infrastructure: reviewing the plan mechanically, containing blast radius, structuring state, and avoiding the destroys nobody noticed.
Terraform State Operations
The state commands that are genuinely dangerous, what each one actually does, and how to run them with a way back.
Tool
Terraform Plan Analyzer
Find destructive and high-risk changes in a Terraform plan before you apply it.
Available now
Newsletter
Inside The AI Stack Brief
A practical weekly briefing on AI engineering, infrastructure, production operations, and the technologies powering the AI stack.
One email a week. No sponsorship placements inside the technical sections. Unsubscribe in one click.