DevOps & SRE No. 170

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.

  • nameterraform-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.

~/.claude/skills/terraform-iac/SKILL.md

--- name: terraform-iac description: Writes and reviews Terraform and OpenTofu infrastructure code — module design, remote state backends with locking, saved-plan review,… license: MIT ---

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), or force-unlock without 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 moved blocks, adopt with import blocks, detach with removed blocks — all reviewable in a plan, unlike state CLI commands.
  • Commit .terraform.lock.hcl. Never commit .tfstate, .terraform/, or *.tfvars containing 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/
ChoiceUse when
Directory per environmentDefault. Different backends/accounts, clear blast radius, easy reviews
WorkspacesIdentical short-lived copies (per-branch previews) on the same backend
Terragrunt / Terraform StacksMany 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_each over maps with stable keys, not count, for collections (removing an item from a count list 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 showsAsk
must be replaced on a database, volume, bucket, or LBWhich attribute forces it? Can it be changed in place or with create_before_destroy?
Destroy + create of resources you only renamedAdd a moved block
Updates you did not makeDrift: someone changed it in the console. Codify or revert
known after apply on IDs feeding for_each keysRestructure; keys must be known at plan time
Changes to IAM policiesRead the diff line by line; least privilege still holds?
Sensitive valuesMarked (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-exitcode nightly to alert on drift.

Gotchas

  • count = var.enabled ? 1 : 0 turns the address into res[0]; switching an existing resource to this pattern needs a moved block.
  • 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 rm followed by forgetting to re-import leaves orphaned cloud resources.
  • depends_on on modules makes every data source inside wait until apply, producing known after apply everywhere. 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 select applies 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 109 lines · 3.9 KB
#!/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.

esc