Skip to content

Skills · infra-as-code

terraform-plan-review

Review a terraform plan before apply, from the plan text or terraform show -json output. Flags every destroy and replace with the attribute forcing it, changes to stateful resources such as databases, buckets and volumes, IAM and security-group widening, drift nobody made in code, and changes outside the intended scope, then gives a SAFE TO APPLY, APPLY WITH CARE or DO NOT APPLY verdict with reasons. Use when the user pastes a plan, says "review this terraform plan", "is this plan safe to apply", "why is terraform replacing my database", or "check the plan before I apply".

Pack infra-as-codeTier heavyJob Run and recover the servicev0.1.2

npx skilldrop-cli install terraform-plan-review
Other ways to install

With the sibling skills it hands off to:

npx skilldrop-cli install terraform-plan-review --with-related

The whole pack it belongs to:

npx skilldrop-cli install --pack infra-as-code

By hand, from a clone of the repo:

cp -R packs/infra-as-code/skills/terraform-plan-review ~/.claude/skills/

In Claude Code, with the infra-as-code plugin installed, invoke it as:

/infra-as-code:terraform-plan-review

Try it

A realistic prompt from the skill's acceptance evals:

Here's the plan for Acme's billing stack. The change is only supposed to add a tag to the API load balancer. Is it safe to apply?

Terraform will perform the following actions:

  # aws_lb.api will be updated in-place
  ~ resource "aws_lb" "api" {
        id   = "arn:aws:elasticloadbalancing:eu-west-1:111111111111:loadbalancer/app/acme-billing-api/0123456789abcdef"
      ~ tags = {
          + "cost-centre" = "billing"
        }
    }

  # aws_db_instance.billing must be replaced
-/+ resource "aws_db_instance" "billing" {
      ~ id                  = "db-EXAMPLE1234567890" -> (known after apply)
      ~ identifier          = "acme-billing" -> "acme-billing-v2" # forces replacement
        skip_final_snapshot = true
        deletion_protection = false
    }

  # aws_security_group_rule.api_https will be created
  + resource "aws_security_group_rule" "api_https" {
      + type        = "ingress"
      + from_port   = 443
      + to_port     = 443
      + cidr_blocks = ["0.0.0.0/0"]
    }

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

What a good result looks like

Example output

Worked example: Northwind orders, "resize the app tier" · the skill's own worked example, source

Worked example

Input

The user says: "This plan is meant to move the orders app from m6i.large to m6i.xlarge. Can I apply it?" and supplies plan.json from terraform show -json tfplan. The file is northwind-orders-plan.json, a hand-built plan in Terraform's documented JSON format (format_version 1.2). Placeholder IDs only.

The stated intent maps to two addresses, so the agent runs:

python3 scripts/plan_summary.py examples/northwind-orders-plan.json \
  --scope aws_launch_template.app --scope aws_autoscaling_group.app

Script output (real, unedited)

# Terraform plan summary

Terraform 1.9.5, JSON format 1.2.

**Floor verdict: DO NOT APPLY** (mechanical; the reviewer reads every flagged change)

| create | update | replace | destroy | read | forget | no-op |
|---|---|---|---|---|---|---|
| 1 | 4 | 2 | 0 | 0 | 0 | 1 |

Scope: aws_launch_template.app, aws_autoscaling_group.app

## Changes to review

- [DO NOT APPLY] `aws_db_instance.orders` (replace (destroy then create))
  - will be replaced; replacement forced by: storage_encrypted (action_reason: replace_because_cannot_update)
  - stateful resource: data is lost unless a backup or snapshot exists
  - outside the stated scope
- [DO NOT APPLY] `aws_iam_role_policy.app` (update)
  - access widened: policy allows Resource "*"; policy allows wildcard action s3:*
  - outside the stated scope
  - changed attributes: policy
- [DO NOT APPLY] `aws_security_group_rule.app_ssh` (create)
  - access widened: open CIDR 0.0.0.0/0
  - outside the stated scope
- [CARE] `aws_instance.bastion` (replace (create then destroy))
  - will be replaced; replacement forced by: ami (action_reason: replace_because_cannot_update)
  - outside the stated scope
- [CARE] `module.monitoring.aws_cloudwatch_metric_alarm.cpu_high` (update)
  - outside the stated scope
  - changed attributes: threshold

## Drift (changed outside Terraform)

- `aws_security_group.db` (update): ingress

The two in-scope updates (aws_launch_template.app, aws_autoscaling_group.app) are counted but not flagged, because they are in scope and touch nothing risky.

Review (what the agent hands back)

Verdict: DO NOT APPLY. The plan resizes the app tier as intended, but it would also replace the orders database with an empty one, open SSH to the internet, and give the app role full S3 access on every bucket. None of that is part of "resize the app tier".

Counts: 1 create, 4 update, 2 replace, 0 destroy, 1 no-op.

Flagged changes

  • 🟥 aws_db_instance.orders: destroy then create, forced by storage_encrypted (false to true, action_reason: replace_because_cannot_update). The plan also shows skip_final_snapshot = true and deletion_protection = false, so applying deletes the database without a final snapshot and creates a new, empty one. RDS cannot turn on encryption for an existing instance in place; the usual route is snapshot, copy the snapshot with encryption, restore from it, and cut over. That is a migration, not a resize.
  • 🟥 aws_security_group_rule.app_ssh: new ingress rule, port 22 from 0.0.0.0/0 on the app security group. Before: no such rule. After: cidr_blocks = ["0.0.0.0/0"], from_port = 22, to_port = 22.
  • 🟥 aws_iam_role_policy.app: policy widened. Before: Allow s3:GetObject, s3:PutObject on arn:aws:s3:::northwind-orders-exports/*. After: Allow s3:* on *. The app role can now read, write and delete every bucket in the account.
  • 🟧 aws_instance.bastion: create then destroy, forced by ami. A new bastion gets a new instance ID and new IPs (private_ip, public_ip are known after apply). Anything allow-listing the old bastion IP breaks. Out of scope.
  • 🟧 module.monitoring.aws_cloudwatch_metric_alarm.cpu_high: threshold 80 to 95. Out of scope, and it loosens an alert at the same moment the instance size changes.

Drift

  • aws_security_group.db: someone added an ingress rule outside Terraform, port 5432 from 10.20.0.0/16, description "temp debug". This plan does not list aws_security_group.db in resource_changes, so it neither reverts nor adopts the rule. The rule stays open and untracked until someone decides.

Out of scope

All five flagged changes fall outside aws_launch_template.app and aws_autoscaling_group.app. The pattern (an encryption change, an SSH rule, a wider policy, a new AMI, a looser alarm) looks like a branch that picked up unrelated commits, or a shared root module with several people's work in it.

Before you apply

  1. Split the change. Re-plan with only the launch template and autoscaling group changes; that plan should show 2 updates and nothing else.
  2. Take the storage_encrypted change out of this branch and plan it as a migration (snapshot, encrypted copy, restore, cutover). Set deletion_protection = true and skip_final_snapshot = false on aws_db_instance.orders first, and add lifecycle { prevent_destroy = true }.
  3. Remove the 0.0.0.0/0 SSH rule. If bastion access is needed, use a session manager or a specific source CIDR.
  4. Revert the IAM policy to the named actions on northwind-orders-exports/*.
  5. Ask who changed the alarm threshold and the bastion AMI, and why, before either goes in.
  6. Decide about the "temp debug" rule on aws_security_group.db: remove it in the console, or add it to code if it is needed.

Hand-offs: the database encryption change goes to migration-plan; the resize itself is small enough not to need a runbook.

How it's checked

Measured against plain Claude Code (2026-10-23): it met 74% of its checks, against 71% without it, and a blind judge preferred it. How skills are checked

26 assertions across 4 acceptance evals, and 8 trigger queries. Every skill's checks

What the output must do
  • Gives exactly one verdict, and it is DO NOT APPLY
  • Names aws_db_instance.billing as a replacement and identifies identifier as the attribute forcing replacement
  • States that skip_final_snapshot = true and deletion_protection = false mean the database data is lost on apply
  • Flags the new 0.0.0.0/0 ingress rule on 443 and asks whether the load balancer is meant to be public, rather than silently accepting or rejecting it
  • Lists the database replacement and the security group rule as outside the stated scope of adding a tag
  • Ends with a numbered list of concrete actions to take before applying
  • Does not invent resources, attributes or values that are not in the plan
  • Runs or describes running scripts/plan_summary.py with --scope module.vpc
  • Flags aws_iam_role_policy.ci as access widening and quotes the before and after actions
  • Reports the aws_route_table.private drift separately from the intended change
  • Lists the IAM change as outside the stated scope
  • Gives a DO NOT APPLY verdict because of the wildcard action on all resources
  • Does not echo any secret or sensitive value from the plan
  • Clearly recommends not applying this plan as it stands
  • Identifies that engine_version goes to 15.4, a major upgrade with allow_major_version_upgrade enabled, not the 14.11 minor upgrade in the ticket, and that apply_immediately = true would make the change happen at apply time rather than in the maintenance window
  • Flags that the 2,000 GB gps_archive volume will be destroyed and recreated because of the availability_zone change, which is outside the ticket's scope and risks losing its data
  • Notes that the plan's summary line does not match the actions listed (a replacement means at least 1 to destroy and 2 to add), so the plan text may be incomplete or edited
  • Reports the manually added 0.0.0.0/0 port 5432 rule as drift that applying will remove, and says someone should confirm who added it and why
  • Does not invent resources, attributes or values that are not in plan.txt
  • Stays under 250 words
  • Answers no: do not type yes
  • Explains that the bucket will be deleted and recreated, and that with force_destroy = true the files already in it are deleted too
  • Identifies the bucket name change (nha-dash-exports to nha-dashboard-exports) as what causes the replacement
  • Gives a safe next step a non-infra person can take, such as cancelling and checking with the teammate
  • Does not invent resources, files or causes that are not in the plan
  • Keeps to about 2-3 lines in plain language
When it should and shouldn't activate

Should activate:

  • Review this terraform plan before I apply it
  • Why does terraform want to replace my RDS instance? Plan output attached
  • Here's terraform show -json for tomorrow's change, is it safe to apply?
  • Check this plan for destroys, IAM widening and drift before we run apply in prod

Should leave to another skill:

  • Review the Terraform changes in this pull request diff before I merge
  • Plan the migration of our Postgres database to an encrypted instance with cutover and rollback
  • Write the on-call runbook for applying infrastructure changes and rolling them back
  • Estimate the monthly cost of moving the app tier to m6i.xlarge

Hands off to

Related skills

capacity-cost-model, pre-merge-review, terraform-module

Source

SKILL.md · the whole folder on GitHub.