原始内容
name: Terraform slug: terraform version: 1.0.4 description: >- Writes, debugs, and refactors Terraform — HCL and modules, plan and apply failures, state surgery, drift, and provider pinning. Use when writing or reviewing HCL, when terraform plan or apply errors out, when a plan shows a permanent diff, an unexplained destroy, or "forces replacement", when renaming, importing, or moving resources between modules and states, when a state lock is stuck or state is lost or corrupted, when for_each fails because a value is not known until apply, when pinning providers or surviving a major version upgrade, or when wiring plan-on-PR, apply-on-merge, drift detection, and policy gates. Covers OpenTofu, tfstate, backends, workspaces, and infrastructure-as-code review. Not for choosing which cloud services to build — see aws, gcp, or azure. homepage: https://clawic.com/skills/terraform changelog: "Display name shown correctly" metadata: clawdbot: emoji: 🟪 requires: anyBins: - terraform - tofu os: - linux - darwin - win32 displayName: Terraform configPaths: - ~/Clawic/data/terraform/
User preferences and memory live in ~/Clawic/data/terraform/ (see setup.md on first use, memory-template.md for the file format). If you have data at an old location (~/terraform/ or ~/clawic/terraform/), move it to ~/Clawic/data/terraform/.
When To Use
- Writing or reviewing HCL: resource design, count vs for_each, module boundaries, variable types and validation
- Debugging plan/apply failures, permanent diffs, cycles, unknown-at-plan errors, stuck locks, drift
- Refactoring live infrastructure: renames, module extraction, imports, splitting or merging state
- Pinning providers, upgrading the CLI or a provider major version, moving between Terraform and OpenTofu
- Designing backends, environment layout, and CI plan/apply gates with policy, tests, and drift detection
- Recovering after damage: lost or corrupted state, wrong
state rm, unintended destroy, interrupted apply - Not for choosing which cloud resources to build (→
aws,gcp,azure) or configuring what runs inside them (→ansible)
Quick Reference
| Situation | Play |
|---|---|
| Renamed a resource or module in code | moved block (>=1.1); apply everywhere, delete the block in a later PR → refactoring.md |
| Cloud object exists but is not in state | import block (>=1.5) + plan -generate-config-out=gen.tf; rewrite the draft, merge at zero diff → refactoring.md |
| Stop managing something without destroying it | removed block with destroy = false (>=1.7); state rm only as one-off surgery → refactoring.md |
| Permanent diff on every plan | Find the writer (autoscaler, console, another pipeline, provider normalization) before reaching for ignore_changes → debug.md |
| "Invalid for_each argument" / "Invalid count argument" | Key on values known at plan time, never on resource attributes → expressions.md |
| A variable value is ignored, or an old value keeps winning | Precedence: -var/-var-file > *.auto.tfvars > terraform.tfvars > TF_VAR_ > the declared default → expressions.md |
| "Error: Cycle: ..." | Break the mutual reference into a standalone rule/attachment resource → debug.md |
| "Provider produced inconsistent final plan" | Provider bug: upgrade the provider, then pin and report → debug.md |
| "Saved plan is stale" | State moved between plan and apply; re-plan, re-review, re-apply → ci.md |
| Stuck state lock | Prove the holder is dead, then force-unlock <LOCK_ID> → recovery.md |
| State lost, corrupted, or pushed wrong | Bucket object versions are the only undo; rebuild by import if there are none → recovery.md |
| "Inconsistent dependency lock file" or a checksum error on install | The lock lacks that provider or that platform → providers.md |
| Two regions, two accounts, one config | Provider alias + explicit providers map into modules → providers.md |
| Plan takes minutes | -refresh=false while iterating, then split state by blast radius → performance.md |
| Throttling or "Rate exceeded" during apply | Lower -parallelism (default 10) → performance.md |
| Moving to a remote backend or between backends | Pull a backup, then init -migrate-state → state.md |
| Secret landed in state, a plan file, or a log | Rotate first; state is plaintext and old versions keep it → secrets.md |
| Provider major upgrade (v4 → v5) | Upgrade guide, one canary stack, explained diff per environment → upgrades.md |
| Module design, versioning, or nesting question | Exact pins for third-party, ~> for internal, two levels max → modules.md |
| Resource must never be destroyed, must rotate with another, or keeps diffing on one attribute | lifecycle meta-arguments and what each one costs → lifecycle.md |
A destroy fails or a teardown leaves debris |
Deletion protection, retained objects, reverse-order dependencies → lifecycle.md |
| Wiring plan-on-PR and apply-on-merge | OIDC role, saved plan artifact, one concurrency group per state → ci.md |
| Need tests or policy gates | .tftest.hcl (>=1.6), plan-JSON assertions, policy engine → testing.md |
| Need the exact command under pressure | → commands.md |
| Anything else | Smallest possible change; plan -out=tfplan; read the destroy count and every "forces replacement" line before applying |
Depth on demand: debug.md plan/apply symptom→cause chains · state.md backends, locking, layout · refactoring.md moved/import/removed · modules.md design and versioning · expressions.md HCL loops, types, functions · lifecycle.md replacement, protection, ignored drift · providers.md pinning, lock file, aliases · secrets.md sensitive values and state exposure · ci.md pipelines, OIDC, drift detection · testing.md validate, terraform test, policy · upgrades.md CLI and provider majors, OpenTofu · performance.md slow plans and big states · recovery.md after the damage · commands.md incident toolkit.
Core Rules
- The saved plan is the contract.
terraform plan -out=tfplan→ review →terraform apply tfplan. A bareapplyre-plans against a world that may have changed since you read the diff: you approve one change and execute another. - Back up before state surgery.
terraform state pull > backup-$(date +%s).tfstate; restore withterraform state push. One wrongstate rmorphans a live resource that keeps running and billing with nothing tracking it. - Read the destroy count from the resource lines, not the summary. A replacement is counted once as an add and once as a destroy, so
2 to add, 0 to change, 2 to destroycan be two replacements rather than two creations plus two deletions. Machine gate:terraform show -json tfplan | jq '[.resource_changes[] | select(.change.actions | index("delete"))] | length'— compare againstdestroy_gate. - Pin everything. Providers via
required_providersplus a committed.terraform.lock.hcl; third-party modules to an exact version; git module sources to a tag, never a branch. Unpinned means CI breaks on someone else's release day. - Key
for_eachon stable, human-chosen strings (environment names, logical roles) — never IDs, list indices, or computed values. Changing a key is destroy + create of the real object;countindices renumber, so removing item 0 shifts everything after it. - Refactor declaratively.
moved,import, andremovedblocks put the change in the PR diff and replay in every environment.state mv/state rmare out-of-band: unreviewable, unreplayable, invisible to the next reader. - Nothing sensitive is safe in state.
sensitive = truemasks CLI output; the value sits in plaintext in the state file, the saved plan, andTF_LOGoutput. Encrypt the backend, restrict who can read state, and prefer ephemeral values (>=1.10) and write-only arguments (>=1.11). - Drift is a question, not an error. When the cloud changed under you, decide explicitly:
apply -refresh-onlyaccepts reality into state, a normal apply overwrites reality with the code. Applying without choosing is how a console hotfix gets silently reverted at 2am.
Plan Triage
Read the symbols before the summary:
| Marker | Means | Reaction |
|---|---|---|
+ |
create | Expected count matches the change you made? |
~ |
update in place | Safe class; still check the attribute is the one you edited |
-/+ |
destroy then create | Downtime and a new ID; find the # forces replacement line |
+/- |
create then destroy | create_before_destroy is on; unique names will collide |
- |
destroy | Needs an explanation you could give in an incident review |
(known after apply) |
value unresolved at plan | Anything keyed on it will fail for_each; anything printed from it is unverifiable now |
Note: Objects have changed outside of Terraform |
drift detected during refresh | Rule 8 — decide before you apply |
terraform show tfplanre-renders a saved plan;terraform show -json tfplanis the machine-readable form every gate should read (ci.md).plan -detailed-exitcodeexit codes: 0 no changes · 1 error · 2 changes present.- "No changes" plus a real-world difference you can see means the attribute is not managed (missing from config, or hidden by
ignore_changes).
Count vs for_each
countis positional. Reserve it for identical replicas and the enable flag:count = var.enabled ? 1 : 0, referenced asone(aws_x.y[*].id).for_eachis keyed and stable, but needs a map or a set of strings (toset()for lists) whose keys are known at plan time. Values may be unknown; keys may not. Keys built from resource attributes fail with "Invalid for_each argument" (expressions.md).- Migrating
counttofor_eachwithout onemovedblock per index destroys and recreates every instance. Get the index→key mapping fromterraform state list, not from memory (worked example inrefactoring.md). - Sensitive values cannot be
for_eachkeys — a map that merges in one sensitive input becomes sensitive as a whole and the plan rejects it.
Version Floors
required_version in the root module turns "my colleague gets a parse error" into a clear message. Floors for the syntax this skill recommends:
| Feature | Floor |
|---|---|
moved blocks |
terraform >=1.1 |
precondition / postcondition, replace_triggered_by |
terraform >=1.2 |
optional() object attributes with defaults |
terraform >=1.3 |
terraform_data (replaces null_resource) |
terraform >=1.4 |
import blocks, check blocks, plan -generate-config-out |
terraform >=1.5 |
terraform test with .tftest.hcl |
terraform >=1.6 |
removed blocks, for_each in import, mock_provider |
terraform >=1.7 |
| Provider-defined functions | terraform >=1.8 |
Ephemeral values and resources, S3 backend use_lockfile |
terraform >=1.10 |
| Write-only arguments | terraform >=1.11 |
OpenTofu forked at the 1.6 line: floors above 1.6 do not transfer — check tofu version against its own changelog before using a newer block (upgrades.md).
Output Gates
Before emitting HCL or proposing an apply:
- Plan saved to a file, and the thing applied is that file?
- Destroy count read from the resource lines, with every "forces replacement" attribute named out loud?
- Every
for_eachkeyed on a plan-time-known string? - Providers pinned and
.terraform.lock.hclcovering every platform inlock_platforms? - New variables typed, with
validationwherever a wrong value is expensive? - No secret in a committed
.tfvars, a variable default, or an unmasked output? - If this is a refactor: does the plan read
0 to add, 0 to change, 0 to destroy?
Configuration
User-dependent variables. Defaults apply until the user states a preference; store them in ~/Clawic/data/terraform/config.yaml.
| Variable | Type | Default | Effect |
|---|---|---|---|
| terraform_binary | terraform | tofu | terraform | Command name in every example; tofu switches version-floor checks to the OpenTofu changelog and enables its native state-encryption guidance |
| primary_provider | aws | gcp | azure | other | aws | Which provider's examples, auth model, and backend appear first in explanations |
| backend_type | s3 | gcs | azurerm | tfc | local | s3 | Locking mechanism, versioning advice, and CI credential wiring |
| env_layout | dir-per-env | workspace-per-env | single-state | dir-per-env | Refactoring and CI examples; workspace-per-env turns on the workspace-safety warnings instead of suppressing them |
| lock_platforms | list | linux_amd64, darwin_arm64 | Platforms passed to terraform providers lock and checked in the Output Gates |
| destroy_gate | number (>=0) | 0 | Destroy count above which the agent stops, names every destroyed address, and asks before proposing an apply |
| parallelism | number (1-50) | 10 | Value used in generated plan/apply commands; lower it when the provider throttles |
| plan_summary_detail | full | destructive-only | counts | destructive-only | How much plan output gets surfaced (chat and the PR comment in ci.md): full renders every changed resource, destructive-only posts counts plus every destroyed, replaced, and "forces replacement" line and collapses the rest, counts posts the summary line and the destroy list only |
Preference areas — customizable dimensions; a stated preference gets recorded in config.yaml and applied:
- Tooling: wrappers (Terragrunt, Terramate), pre-commit hooks, plan-summary tooling — affects which pipeline shape gets proposed
- Conventions: resource and module naming, tagging standard, file split (main/variables/outputs vs per-domain) — affects every generated block
- Platform: clouds, regions, and accounts in play, and the cross-account role-assumption pattern — affects provider aliases and backend keys
- Safety posture: appetite for
statesurgery vs declarative blocks, whether-auto-approveis ever acceptable — affects which refactoring path is offered first - Workflow: where apply happens (laptop, CI, managed platform), review gates, who holds production credentials — affects the CI examples
- Compliance: mandatory policy engine, required tags, encryption and public-access rules — affects the testing and gate recommendations
- Output format: plan-summary verbosity beyond
plan_summary_detail, HCL-vs-explanation ratio in answers, proactive warnings versus on-demand — affects how every plan and review is reported - Cadence: drift-detection schedule and provider-upgrade rhythm — affects what gets scheduled versus run on demand
Traps
| Trap | Why it fails | Do instead |
|---|---|---|
| CLI workspaces for dev/prod separation | Same backend, same credentials; the active workspace is invisible CLI state — applying in the wrong one looks identical to the right one | Directory per environment with separate backends and separate cloud roles (state.md) |
Routine -target applies |
Leaves the graph partially applied; the next full plan is a surprise diff nobody scoped | Emergencies only, always followed by a clean full plan |
Interpolation in the backend block |
Backends cannot read variables or locals — the block is evaluated before anything else exists | Partial config: omit the keys and pass -backend-config=env/prod.tfbackend |
| Provisioners for configuration | Not idempotent, untracked in state; a failed provisioner taints the whole resource | user_data/cloud-init, config management, or terraform_data (>=1.4) |
| Hand-editing state JSON | Serial and lineage mismatch corrupts the backend copy — or worse, the push succeeds | state mv/rm/push on a pulled backup (Core Rules 2) |
ignore_changes = all |
Freezes the entire resource forever; future config edits become silent no-ops | Ignore the one attribute, with a comment saying who writes it (lifecycle.md) |
| Treating plan success as apply safety | Plan validates config against state, not against the cloud: quotas, IAM, name collisions, and eventual consistency all surface at apply | Apply early in a sandbox account; keep changes small so failures are attributable |
apply -auto-approve outside CI |
Removes the only human checkpoint between a typo and deleted production | Auto-approve only in a pipeline applying a reviewed saved plan |
Committing terraform.tfstate or .terraform/ |
Ships every secret in state to git history and leaves everyone on a different copy | Gitignore both; commit .terraform.lock.hcl |
Module source pinned to a branch (?ref=main) |
The build changes under you with no diff in your repo | Tag refs (?ref=v1.2.3) or registry versions |
depends_on sprinkled to fix ordering |
Hides a missing attribute reference and, at module level, defers every data source inside to apply time | Reference the attribute you actually need; pass explicit values between modules |
Where Experts Disagree
- Vanilla Terraform vs wrapper tooling (Terragrunt and friends): the frontier is duplication — one team with a handful of stacks loses more to wrapper complexity than it saves; once environments × stacks means maintaining dozens of near-identical backend and provider blocks, DRY tooling earns its cost.
- Exact module pins vs
~>constraints: exact pins for third-party registry modules (supply-chain surface); pessimistic minor constraints acceptable for internal modules gated by your own CI. - One shared state vs many micro-states: the frontier is change coupling — resources that always ship together belong in one state; every cross-state reference costs a data-source hop and an ordering problem between pipelines.
- Terraform vs OpenTofu: the frontier is licensing exposure and feature need, not ideology — OpenTofu is a drop-in for most existing code and adds state encryption; teams already on a managed HashiCorp platform, or depending on features that landed after the fork, pay a migration cost for nothing. Decide once, per organization (
upgrades.md).
Related Skills
More Clawic skills, get them at https://clawic.com/skills/terraform (install if the user confirms):
- aws — provider-specific resource and service guidance
- devops — pipeline and delivery design around plan/apply gates
- github-actions — wiring plan-on-PR / apply-on-merge workflows
- ansible — configuring what lives inside the instances Terraform creates
- k8s — workloads on the clusters Terraform provisions
Feedback
- If useful, star it: https://clawic.com/skills/terraform
- Latest version: https://clawic.com/skills/terraform
Part of Clawic, the verified skill library. Get this skill: https://clawic.com/skills/terraform.