Terraform Operations

SkillCloud & infra

Terraform and OpenTofu infrastructure-as-code operations - project layout, state management, module design, plan/apply safety, CI/CD pipelines, and secrets. Use for: terraform, opentofu, infrastructure as code, IaC, tfstate, terraform state, terraform module, remote backend, terraform plan, terraform apply, for_each, moved block, terraform import, drift detection, tflint, checkov, HCL.

Available today. Use it from your connected AI after setup.

Connect ahel once, and every AI you use reads what you have installed.

Then ask your AI: use the Terraform Operations skill

What this skill tells your AI

The instructions your AI receives, as published by 0xdarkmatter/claude-mods in skills/terraform-ops/SKILL.md and read by ahel’s review.

Terraform / OpenTofu infrastructure-as-code: layout, state, modules, safety, CI/CD, secrets.

Version context (verified 2026-06): Terraform 1.15.x (BUSL-1.1 licence since 1.6) · OpenTofu 1.12.x (MPL-2.0 fork of Terraform 1.5.x). Commands below are interchangeable (terraformtofu) unless flagged. See Terraform vs OpenTofu for the decision note.

Reference Files

FileCovers
references/state-management.mdRemote backends, locking, moved/import/removed blocks, state surgery, drift detection
references/module-patterns.mdModule composition, variable validation, optional/nullable, output contracts, versioning
references/cicd-pipelines.mdGitHub Actions plan/apply, OIDC auth, policy gates (tflint/trivy/checkov/OPA), Atlantis/HCP
references/security-and-secrets.mdSecrets in state, ephemeral resources, write-only arguments, SOPS/Vault, sensitive limits
assets/github-actions-terraform.ymlReady-to-adapt PR-plan + OIDC-apply workflow
scripts/check-action-refs.shStaleness verifier for any workflow's uses: action refs (offline structural / live API resolve)

The action versions pinned in github-actions-terraform.yml are point-in-time (verified 2026-06). Run scripts/check-action-refs.sh --live before adopting — a tag that was valid at write time may have been retracted or never existed (e.g. trivy-action@0.33.1 vs the real v0.33.1).

Project Layout Decision Tree

How many environments / accounts?
│
├─ One environment, one team
│  └─ Single root module + tfvars. Don't over-engineer.
│
├─ Multiple environments (dev/staging/prod)
│  ├─ Need different backend/account/region per env? (usually YES for prod isolation)
│  │  └─ DIRECTORY PER ENVIRONMENT (recommended default)
│  │     environments/{dev,staging,prod}/ each a thin root calling shared modules
│  │
│  └─ Environments truly identical except a few variables, same backend account?
│     └─ Workspaces are *acceptable* — but see the workspace caveats below
│
└─ Many teams / many state files / platform engineering
   └─ Directory-per-env + per-component state split (network / data / app)
      Consider Terragrunt, Terraform Stacks (HCP), or OpenTofu + CI orchestration

Canonical multi-env layout

infra/
├── modules/                  # Reusable child modules (no provider/backend blocks)
│   ├── network/
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   └── versions.tf       # required_providers ONLY (no provider config)
│   └── app-service/
├── environments/             # Root modules — one state file each
│   ├── dev/
│   │   ├── main.tf           # module "network" { source = "../../modules/network" ... }
│   │   ├── backend.tf        # remote backend, env-specific key
│   │   ├── providers.tf      # provider config lives in ROOT only
│   │   ├── terraform.tfvars  # committed, non-secret env values
│   │   └── versions.tf       # required_version + required_providers pins
│   └── prod/
└── .tflint.hcl

Why directories usually beat workspaces

ConcernDirectoriesWorkspaces
Separate backend/account per envYes — each root has its own backend.tfNo — one backend, envs differ only by state key
Blast radius of wrong-env applyLow — you're physically in prod/High — invisible terraform workspace select state
Env-specific config divergenceNatural (different main.tf if needed)terraform.workspace conditionals creep everywhere
Prod IAM isolationPer-dir CI roleSame credentials see all envs
Visibility in code reviewDiff shows which env changedWorkspace is runtime state, not in the diff

Workspaces fit short-lived ephemeral copies (PR preview envs) — not the dev/prod boundary. HashiCorp's own docs say workspaces are "not suitable for strong separation."

tfvars conventions

terraform.tfvars            # auto-loaded — per-root committed defaults (non-secret)
*.auto.tfvars               # auto-loaded — generated/local overrides
prod.tfvars                 # explicit only: terraform plan -var-file=prod.tfvars
TF_VAR_db_password=...      # env var injection — secrets in CI, never in files

Gotcha: -var-file + directories-per-env is belt-and-braces; with workspaces it's load-bearing and one forgotten flag applies dev values to prod.

State Quick Reference

Full detail: references/state-management.md.

TaskCommand / blockNotes
Remote backend (AWS)backend "s3" { bucket, key, region, use_lockfile = true }S3-native locking (TF ≥1.10) — DynamoDB table no longer required
Rename resource in codemoved { from = aws_x.a, to = aws_x.b }Declarative, reviewable, no CLI surgery
Adopt existing infraimport { to = aws_x.a, id = "i-123" } + plan -generate-config-out=gen.tfConfig-driven import (TF ≥1.5) beats terraform import CLI
Forget without destroyremoved { from = aws_x.a, lifecycle { destroy = false } }TF ≥1.7; OpenTofu 1.12 also has lifecycle { destroy = false } on resources
Drift detectionterraform plan -detailed-exitcodeExit 0 = clean, 1 = error, 2 = drift — cron it
Inspect stateterraform state list / state show ADDRRead-only, always safe
Move state (last resort)terraform state mv SRC DSTPrefer moved blocks — see "when NOT to" below
Pull/push (emergency)terraform state pull > backup.tfstateALWAYS pull a backup before any surgery

State surgery — when NOT to: if a moved/removed/import block can express it, use the block. CLI state mv/rm is immediate, unreviewed, unversioned, and a typo orphans real infrastructure. Legit uses: splitting state between roots, unwedging a failed migration. Always state pull a backup first.

Module Quick Reference

Full detail: references/module-patterns.md.

module "network" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 6.0"          # pin minor-float for registry modules; exact pin in prod roots
  # ...
}
RuleWhy
Composition over inheritanceRoots compose flat modules; never module-wraps-module-wraps-module
No provider blocks in child modulesProviders configured in root only; child declares required_providers
validation blocks on variablesFail at plan with a real message, not mid-apply
optional(type, default) in object attrsCallers omit fields; nullable = false rejects explicit null
Outputs are the contractOutput IDs/ARNs consumers need; document with description
Anti-pattern: thin wrappersA module that just renames variables of another module adds a version-lag layer and zero value — call the upstream module directly

Safety Checklist (before every apply)

□ plan output READ, not skimmed — every destroy/replace explained
□ "Plan: X to add, Y to change, Z to destroy" — does Z surprise you?
□ -/+ (replace) lines: check the "forces replacement" attribute
□ Applying the SAME saved plan that was reviewed: plan -out=tfplan → apply tfplan
□ prevent_destroy on stateful resources (db, state bucket, KMS keys)
□ Cloud-side deletion protection too (RDS deletion_protection, S3 versioning+MFA-delete)
□ No -target unless this is a declared emergency (see below)
□ for_each (stable keys), not count, for any collection that can reorder

Footguns

FootgunDetailFix
count index shiftRemoving item 0 of a count list re-addresses every later item → destroy/recreate cascadefor_each with stable string keys
-target habitSkips dependency graph; state diverges from config; hides driftEmergency-only (broken dependency cycle, partial outage). Follow with a full clean plan
prevent_destroy false comfortDoesn't survive the block being deleted, and doesn't stop state rm + console deletePair with cloud-native deletion protection
Dynamic blocks everywheredynamic for 2 static blocks is obfuscationUse dynamic only over genuinely variable collections
Unpinned providersaws = ">= 5.0" in prod pulls a breaking major the day it ships~> 6.12 + commit .terraform.lock.hcl
Apply ≠ reviewed planPlan on PR, apply on merge re-plans — drift in between applies unreviewed changesSave the plan artifact, or accept + re-review the merge plan
resource "aws_db_instance" "main" {
  deletion_protection = true            # cloud-side
  lifecycle {
    prevent_destroy = true              # terraform-side
    ignore_changes  = [password]        # if rotated outside TF
  }
}

CI/CD Quick Reference

Full detail: references/cicd-pipelines.md · template: assets/github-actions-terraform.yml.

PR opened   → fmt -check → validate → tflint → trivy/checkov → plan → plan posted as PR comment
PR merged   → plan (fresh) → apply, authenticated via OIDC — no long-lived cloud keys
Nightly     → plan -detailed-exitcode → exit 2 ⇒ drift alert
  • OIDC everywhereaws-actions/configure-aws-credentials with role-to-assume, never AWS_ACCESS_KEY_ID secrets. Same supply-chain doctrine as this repo's rules: short-lived tokens, no standing credentials.
  • Pin action SHAs in workflows (uses: actions/checkout@<sha>), not floating tags.
  • Policy gates: tflint (provider-aware lint), trivy config / checkov (misconfig scan), OPA/conftest for org policy ("no public buckets").
OrchestratorFit
Plain GitHub ActionsDefault — full control, free, template in assets/
AtlantisSelf-hosted PR automation, atlantis plan/apply comments, locking per dir
HCP Terraform / Terraform CloudManaged runs, Sentinel policy, state hosting; free ≤500 resources
Spacelift / env0 / Digger / ScalrCommercial Atlantis-likes; Digger runs inside your Actions

Verification — uses: ref staleness

GitHub Action versions rot: a tag gets retracted, or a workflow pins one that never existed. scripts/check-action-refs.sh lints every uses: owner/repo@ref line. It's general — pass any workflow file(s) as positionals (default: this skill's own assets/github-actions-terraform.yml).

# Structural only, no network — well-formedness of every uses: ref (CI-safe gate).
# Floating @main/@master → WARN (exit 0; use --strict to fail). Malformed → exit 4.
scripts/check-action-refs.sh --offline .github/workflows/ci.yml

# Live — resolve each ref against the GitHub API. A 404 (ref doesn't exist) → exit 10
# DRIFT; API unreachable/rate-limited → exit 7 (advisory, never fails the build, §7).
# Set GITHUB_TOKEN to dodge the unauthenticated rate limit.
GITHUB_TOKEN=$GH_PAT scripts/check-action-refs.sh --live .github/workflows/*.yml

scripts/check-action-refs.sh --json --offline | jq '.data[] | select(.status!="ok")'

--live is the check that catches the classic aquasecurity/trivy-action@0.33.1 mistake — that tag 404s; the real one is v0.33.1. Run live on a schedule (never as a blocking PR gate), offline in PR CI.

Testing Quick Reference

# tests/network.tftest.hcl  — native test framework (TF ≥1.6 / OpenTofu ≥1.6)
variables { cidr = "10.0.0.0/16" }

run "valid_cidr_plan" {
  command = plan                          # plan = fast unit-ish; apply = real integration
  assert {
    condition     = aws_vpc.main.cidr_block == "10.0.0.0/16"
    error_message = "VPC CIDR did not match input"
  }
}

run "rejects_tiny_cidr" {
  command = plan
  variables { cidr = "10.0.0.0/30" }
  expect_failures = [var.cidr]            # asserts the validation block fires
}

terraform test runs every *.tftest.hcl under tests/; command = apply runs create real (then auto-destroyed) infra — use a sandbox account. Mock providers (mock_provider blocks, TF ≥1.7) fake apply without credentials. For multi-tool/Go-level orchestration (retry, real HTTP probes), Terratest is the heavyweight alternative — native terraform test covers most module CI needs first.

Secrets Quick Reference

Full detail: references/security-and-secrets.md.

MechanismVersionWhat it does
sensitive = trueallRedacts from CLI output only — value still plaintext in state
Ephemeral resources (ephemeral "...")TF ≥1.10 / OpenTofu ≥1.11Fetch secret at run time; never persisted to state or plan
Write-only arguments (password_wo)TF ≥1.11 / OpenTofu ≥1.11Send secret to provider; never stored in state; rotate via _wo_version
SOPS-encrypted tfvarstoolSecrets encrypted at rest in git; decrypted at plan time
Vault / cloud secret managertoolReference by ID; resource reads secret at boot, TF never sees it
OpenTofu state encryptionOpenTofu ≥1.7Client-side AES-GCM encryption of state/plan — no Terraform equivalent

Rule zero: treat state as secret regardless. Encrypt the backend (SSE-KMS), restrict IAM on the bucket, never commit *.tfstate (gitignore it).

Terraform vs OpenTofu

TerraformOpenTofu
LicenceBUSL-1.1 since 1.6 (no production use competing with HashiCorp; fine for normal internal use)MPL-2.0 — genuinely open source, Linux Foundation
Current1.15.x1.12.x
Exclusive featuresStacks (HCP-tied), Terraform Cloud agents, terraform queryState/plan encryption, provider for_each iteration, -exclude flag, early variable eval in backend/module blocks, OCI registry distribution, .tofu file extension
Registryregistry.terraform.ioregistry.opentofu.org (mirrors most providers)
CompatibilityForked at 1.5.x; HCL/state compatible for mainstream use, diverging feature-by-feature since

Decision: vendors and anyone redistributing IaC tooling commercially → OpenTofu (licence risk). Teams on HCP Terraform/Sentinel → Terraform. Everyone else: either works; OpenTofu's state encryption is the single biggest technical differentiator. Migration terraform → tofu is tofu init + state-compatible up to ~1.8-era features; the gap widens each release — migrate early or commit.

Command Quick Reference

terraform init -upgrade               # init / upgrade providers within constraints
terraform fmt -recursive -check       # CI: fail on unformatted
terraform validate                    # syntax + internal consistency (no creds needed after init)
terraform plan -out=tfplan            # save plan for exact-apply
terraform show -json tfplan | jq      # machine-readable plan (policy tools eat this)
terraform apply tfplan                # apply EXACTLY the reviewed plan
terraform plan -detailed-exitcode     # 0 clean / 2 drift — for cron drift checks
terraform plan -refresh-only          # show drift without proposing config changes
terraform apply -replace=aws_x.a      # force recreate one resource (replaces old taint)
terraform state pull > backup.json    # ALWAYS before surgery
terraform output -json                # consume outputs in scripts
terraform graph | dot -Tsvg > g.svg   # dependency graph
tofu init                             # OpenTofu: same verbs throughout

Signals

GitHub stars
36
Forks
5
Last commit
Aug 2026
Advanced
Catalog kind
skill
Gateway key
terraform-ops
Source
github.com/0xdarkmatter/claude-mods