Terraform IaC skill for Claude Code
Read the plan before the plan reads you. Claude structures Terraform or OpenTofu code as per-environment roots over typed modules, pins providers, and keeps state remote and locked. Every change goes through fmt, validate, tflint and a saved plan that a bundled script summarizes, flagging replacements of databases and buckets. Renames use moved blocks and adoptions use import blocks, and nothing is applied without approval.
- name
terraform-iac - size222 lines · ≈1,900 tokens
- files2
- licenseMIT
- updated
What Claude sees before loading it
This description is the only part of the skill that sits in context all the time. Claude reads it to decide when to load the full playbook below.
description: Writes and reviews Terraform and OpenTofu infrastructure code — module design, remote state backends with locking, saved-plan review, environment layout, drift detection, import/moved/removed blocks, and provider pinning — and reads plans to catch destroys and replacements before apply. Use when writing .tf files, reviewing a terraform plan, importing existing cloud resources, refactoring modules without recreating resources, or fixing state lock and drift problems. Try asking
- Import our existing S3 buckets and RDS instance into Terraform without recreating them
- Review this terraform plan and tell me if anything dangerous is happening
- Split this 900-line main.tf into modules without destroying any resources
Use it when
- You are writing or reviewing .tf files
- A plan shows unexpected replacements or destroys
- Existing cloud resources need to come under Terraform management
- State is locked, drifted, or needs restructuring
Skip it when
- The stack is defined in TypeScript with AWS CDK — use aws-cdk instead
- You are configuring software on existing servers — use ansible-playbooks instead
How to check the result
fmt, validate and tflint pass, the saved plan contains only the intended changes, and a second plan right after apply reports no changes.
The common mistake it prevents
Renaming a resource or switching count to for_each without a moved block, so Terraform plans to destroy and recreate live infrastructure.
Infrastructure code is done when fmt, validate, and lint pass, the saved plan shows
only the intended changes (no surprise destroys or replacements), state is remote and
locked, and the same code applies identically in every environment through variables.
Ground rules
- Never run
apply,destroy,state rm,state mv,import(CLI), orforce-unlockwithout explicit user confirmation, and always apply a saved plan file that the user has seen. - Read every
-/+(replace) and-(destroy) line in a plan before proceeding. - Refactor with
movedblocks, adopt withimportblocks, detach withremovedblocks — all reviewable in a plan, unlike state CLI commands. - Commit
.terraform.lock.hcl. Never commit.tfstate,.terraform/, or*.tfvarscontaining secrets. - Pin Terraform/OpenTofu and provider versions; upgrade deliberately with
init -upgrade.
Workflow
- [ ] 1. Inspect: versions, backend, workspaces/environments, current drift
- [ ] 2. Make the change in code (module, variables, outputs)
- [ ] 3. fmt, validate, lint, security scan
- [ ] 4. plan -out, then summarize the plan
- [ ] 5. Review destroys/replacements with the user
- [ ] 6. Apply the saved plan (after confirmation)
- [ ] 7. Re-plan: expect "No changes"
1. Inspect
terraform version # or: tofu version
cat versions.tf backend.tf 2>/dev/null
terraform init -input=false
terraform workspace show
terraform plan -refresh-only -input=false # drift only: what changed outside Terraform
2. Layout and module design
Default layout: one root module per environment, sharing local modules.
infra/
├── modules/
│ └── service/ # main.tf variables.tf outputs.tf versions.tf README.md
└── envs/
├── staging/ # backend.tf main.tf terraform.tfvars
└── prod/
| Choice | Use when |
|---|---|
| Directory per environment | Default. Different backends/accounts, clear blast radius, easy reviews |
| Workspaces | Identical short-lived copies (per-branch previews) on the same backend |
| Terragrunt / Terraform Stacks | Many accounts/regions with heavy repetition; team already uses it |
Module rules:
- Inputs typed and validated; no provider blocks inside reusable modules.
- Outputs expose IDs/ARNs consumers need; nothing else.
- Use
for_eachover maps with stable keys, notcount, for collections (removing an item from acountlist shifts indexes and recreates everything after it).
# versions.tf
terraform {
required_version = ">= 1.10"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
}
}
# backend.tf — S3 with native lockfile (Terraform 1.10+; DynamoDB locking is deprecated)
terraform {
backend "s3" {
bucket = "acme-tfstate-prod"
key = "services/orders/terraform.tfstate"
region = "eu-west-1"
encrypt = true
use_lockfile = true
}
}
# variables.tf
variable "instance_count" {
type = number
description = "Number of worker instances"
validation {
condition = var.instance_count >= 1 && var.instance_count <= 20
error_message = "instance_count must be between 1 and 20."
}
}
Protect stateful resources:
resource "aws_db_instance" "main" {
# ...
deletion_protection = true
lifecycle {
prevent_destroy = true
ignore_changes = [password] # only for attributes managed elsewhere
}
}
3. Static checks
terraform fmt -check -recursive
terraform validate
tflint --recursive # provider-aware lint
trivy config . # or checkov -d . : misconfig scan
4. Plan and summarize
terraform plan -input=false -out=tfplan
terraform show -json tfplan > tfplan.json
python3 scripts/plan_summary.py tfplan.json # bundled; exits 2 on destroy/replace
The script groups creates, updates, replaces, and deletes by address and prints why
each replacement happens (replace_paths). Use it to drive step 5.
5. Review the plan
| Plan shows | Ask |
|---|---|
must be replaced on a database, volume, bucket, or LB | Which attribute forces it? Can it be changed in place or with create_before_destroy? |
| Destroy + create of resources you only renamed | Add a moved block |
| Updates you did not make | Drift: someone changed it in the console. Codify or revert |
known after apply on IDs feeding for_each keys | Restructure; keys must be known at plan time |
| Changes to IAM policies | Read the diff line by line; least privilege still holds? |
| Sensitive values | Marked (sensitive value); confirm they are not also in outputs |
Refactor and adopt without recreation
# Renamed or moved into a module
moved {
from = aws_s3_bucket.logs
to = module.logging.aws_s3_bucket.this
}
# Adopt an existing resource (Terraform 1.5+, OpenTofu 1.5+)
import {
to = aws_s3_bucket.assets
id = "acme-assets-prod"
}
# Stop managing without destroying (Terraform 1.7+)
removed {
from = aws_instance.legacy
lifecycle { destroy = false }
}
Generate starter config for imports: terraform plan -generate-config-out=generated.tf,
then clean the generated code into the real files.
6–7. Apply and confirm convergence
terraform apply tfplan # only after the user approves this exact plan
terraform plan -detailed-exitcode # 0 = no changes, 2 = changes remain, 1 = error
A non-empty plan right after apply means a perpetual diff (provider normalization,
ignore_changes needed, or a computed value in config). Fix it before moving on.
Secrets
- Pass secrets from a secret manager via data sources or, on Terraform 1.11+, write-only arguments / ephemeral resources so they never land in state.
- Assume state contains secrets: encrypt the backend, restrict access, never paste state into chats or tickets. OpenTofu also supports client-side state encryption.
- Mark outputs
sensitive = true; this hides them from CLI output, not from state.
CI pattern
- PR:
fmt -check,validate,tflint,plan -out, post the plan summary as a comment. - Merge: apply the exact saved plan artifact from the PR job if still fresh, or re-plan and require a manual approval on the protected environment.
- Authenticate CI with OIDC to the cloud provider, never long-lived keys.
- Schedule
plan -refresh-only -detailed-exitcodenightly to alert on drift.
Gotchas
count = var.enabled ? 1 : 0turns the address intores[0]; switching an existing resource to this pattern needs amovedblock.- Provider major upgrades (e.g. AWS v5 -> v6) change defaults and schemas; read the upgrade guide and plan in a non-prod environment first.
terraform state rmfollowed by forgetting to re-import leaves orphaned cloud resources.depends_onon modules makes every data source inside wait until apply, producingknown after applyeverywhere. Use it sparingly.- A stale lock after a crashed run: confirm no run is active before
force-unlock <ID>. - Workspaces share the backend config; a mistaken
workspace selectapplies prod changes with staging variables. Print the workspace in every CI log.
Output
**Root module:** envs/prod (Terraform 1.13.x, aws ~> 6.0)
**Checks:** fmt OK, validate OK, tflint 0 issues, trivy 0 HIGH
**Plan:** 3 to add, 2 to change, 0 to replace, 0 to destroy
- + aws_sqs_queue.orders_dlq
- ~ aws_lambda_function.worker (memory_size 512 -> 1024)
**Drift found:** none
**Apply:** pending user approval of tfplan (sha256 1f3c...) Bundled files
Loaded only when a step in the skill needs them. Review them here before installing.
scripts/plan_summary.py
#!/usr/bin/env python3
"""Summarize a Terraform/OpenTofu JSON plan and flag destructive changes.
Usage:
terraform plan -out=tfplan && terraform show -json tfplan > tfplan.json
python3 scripts/plan_summary.py tfplan.json
Exit codes: 0 = no destroys/replacements, 2 = destroys or replacements present,
1 = input error. Stdlib only.
"""
import json
import sys
STATEFUL_HINTS = (
"db_instance", "rds_cluster", "dynamodb_table", "s3_bucket", "ebs_volume",
"efs_file_system", "elasticache", "sql_database_instance", "storage_account",
"disk", "kms_key", "secretsmanager_secret", "lb", "eip", "route53_zone",
)
def classify(actions):
if actions == ["no-op"] or actions == ["read"]:
return None
if actions == ["create"]:
return "create"
if actions == ["update"]:
return "update"
if actions == ["delete"]:
return "delete"
if sorted(actions) == ["create", "delete"]:
return "replace"
return "+".join(actions)
def main():
if len(sys.argv) != 2:
print(__doc__.strip(), file=sys.stderr)
return 1
try:
with open(sys.argv[1], encoding="utf-8") as fh:
plan = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
print(f"error: cannot read plan JSON: {exc}", file=sys.stderr)
return 1
groups = {"create": [], "update": [], "replace": [], "delete": []}
other = []
for rc in plan.get("resource_changes", []):
kind = classify(rc.get("change", {}).get("actions", []))
if kind is None:
continue
entry = rc
(groups[kind] if kind in groups else other).append(entry)
drift = [d.get("address") for d in plan.get("resource_drift", [])]
print(f"Terraform {plan.get('terraform_version', '?')} plan summary")
print(
f" create={len(groups['create'])} update={len(groups['update'])} "
f"replace={len(groups['replace'])} delete={len(groups['delete'])}"
)
symbols = {"create": "+", "update": "~", "replace": "-/+", "delete": "-"}
for kind in ("delete", "replace", "update", "create"):
if not groups[kind]:
continue
print(f"\n{kind.upper()}")
for rc in groups[kind]:
addr = rc.get("address")
flag = ""
if kind in ("delete", "replace") and any(h in rc.get("type", "") for h in STATEFUL_HINTS):
flag = " <-- STATEFUL: confirm data impact"
print(f" {symbols[kind]:>3} {addr}{flag}")
if kind == "replace":
paths = rc.get("change", {}).get("replace_paths") or []
reason = rc.get("action_reason")
if paths:
readable = [".".join(str(p) for p in path) for path in paths]
print(f" forced by: {', '.join(readable)}")
if reason:
print(f" reason: {reason}")
if kind == "update":
before = rc["change"].get("before") or {}
after = rc["change"].get("after") or {}
changed = sorted(
k for k in set(before) | set(after)
if before.get(k) != after.get(k) and k not in ("tags_all",)
)
if changed:
shown = ", ".join(changed[:8]) + (" ..." if len(changed) > 8 else "")
print(f" attributes: {shown}")
if other:
print("\nOTHER")
for rc in other:
print(f" ? {rc.get('address')} {rc['change']['actions']}")
if drift:
print("\nDRIFT detected outside Terraform (refresh):")
for addr in drift:
print(f" ! {addr}")
destructive = len(groups["delete"]) + len(groups["replace"])
if destructive:
print(f"\nRESULT: {destructive} destructive change(s). Get explicit approval before apply.")
return 2
print("\nRESULT: no destroys or replacements.")
return 0
if __name__ == "__main__":
sys.exit(main())
About this skill
Written and maintained by the Claude Skills editors to the same editorial standard as every skill in the directory: a selective trigger, a real workflow, known pitfalls and a verification step. First published , last updated . MIT licensed.