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.
---
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
1. **The saved plan is the contract.** `terraform plan -out=tfplan` → review → `terraform apply tfplan`. A bare `apply` re-plans against a world that may have changed since you read the diff: you approve one change and execute another.
2. **Back up before state surgery.** `terraform state pull > backup-$(date +%s).tfstate`; restore with `terraform state push`. One wrong `state rm` orphans a live resource that keeps running and billing with nothing tracking it.
3. **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 destroy` can 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 against `destroy_gate`.
4. **Pin everything.** Providers via `required_providers` plus 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.
5. **Key `for_each` on 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; `count` indices renumber, so removing item 0 shifts everything after it.
6. **Refactor declaratively.** `moved`, `import`, and `removed` blocks put the change in the PR diff and replay in every environment. `state mv`/`state rm` are out-of-band: unreviewable, unreplayable, invisible to the next reader.
7. **Nothing sensitive is safe in state.** `sensitive = true` masks CLI output; the value sits in plaintext in the state file, the saved plan, and `TF_LOG` output. Encrypt the backend, restrict who can read state, and prefer ephemeral values (>=1.10) and write-only arguments (>=1.11).
8. **Drift is a question, not an error.** When the cloud changed under you, decide explicitly: `apply -refresh-only` accepts 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 tfplan` re-renders a saved plan; `terraform show -json tfplan` is the machine-readable form every gate should read (`ci.md`).
- `plan -detailed-exitcode` exit 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
- `count` is positional. Reserve it for identical replicas and the enable flag: `count = var.enabled ? 1 : 0`, referenced as `one(aws_x.y[*].id)`.
- `for_each` is 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 `count` to `for_each` without one `moved` block per index destroys and recreates every instance. Get the index→key mapping from `terraform state list`, not from memory (worked example in `refactoring.md`).
- Sensitive values cannot be `for_each` keys — 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_each` keyed on a plan-time-known string?
- Providers pinned and `.terraform.lock.hcl` covering every platform in `lock_platforms`?
- New variables typed, with `validation` wherever 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 `state` surgery vs declarative blocks, whether `-auto-approve` is 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](https://clawic.com/skills/aws)** — provider-specific resource and service guidance
- **[devops](https://clawic.com/skills/devops)** — pipeline and delivery design around plan/apply gates
- **[github-actions](https://clawic.com/skills/github-actions)** — wiring plan-on-PR / apply-on-merge workflows
- **[ansible](https://clawic.com/skills/ansible)** — configuring what lives inside the instances Terraform creates
- **[k8s](https://clawic.com/skills/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](https://clawic.com), the verified skill library. Get this skill: https://clawic.com/skills/terraform.
don't have the plugin yet? install it then click "run inline in claude" again.
this skill covers writing, debugging, and refactoring terraform infrastructure-as-code at scale. use it when you're authoring hcl or modules, when a plan or apply fails, when you spot a permanent diff or unexpected destroy, when refactoring live infrastructure (renames, imports, state splitting), when pinning providers or upgrading major versions, when wiring plan-on-pr and apply-on-merge gates, or when recovering from state corruption. it covers opentofu, tfstate formats, backends, workspaces, and policy gates. it does not cover choosing which cloud services to build (that's aws, gcp, azure) or configuring what runs inside them (that's ansible).
local state and config:
terraform or tofu command in path (>=1.1 recommended for moved blocks; >=1.5 for import blocks; >=1.7 for removed blocks)main.tf, variables.tf, .terraform.lock.hcl~/Clawic/data/terraform/config.yaml (created on first run), storing terraform_binary, primary_provider, backend_type, env_layout, lock_platforms, destroy_gate, parallelism, plan_summary_detailterraform.tfstate (local, never committed), or remote backend state (s3, gcs, azurerm, tfc, etc.)-out=tfplan artifact from a prior terraform plan (required for reviewing critical changes before apply)cloud credentials and backends:
~/.aws/credentials or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, or iam role assumed via oidc in ciGOOGLE_APPLICATION_CREDENTIALS pointing to a service account json, or workload identity in ciARM_SUBSCRIPTION_ID, ARM_CLIENT_ID, ARM_CLIENT_SECRET, or managed identity in ciTF_CLOUD_ORGANIZATION env var and api token in ~/.terraform/rc or TF_API_TOKEN (if using tfc backend or policy gates)external integrations (optional):
terraform show -json tfplanterraform plan -json jobs with drift summary posting to slack, email, or pagerdutyworkflow: plan, review, apply
initialize your working directory. run terraform init in the repo root. outputs: .terraform/ directory (lock local provider cache), .terraform.lock.hcl (committed; describes locked versions for all platforms in lock_platforms), and a backend state reference (local or remote). inputs: main.tf (or any .tf file) with required_providers block specifying provider names and version constraints, and a terraform block with backend config (s3, gcs, tfc, etc.) or partial backend config passed via -backend-config flags.
validate syntax and schema. run terraform validate. outputs: pass (exit 0) or fail (exit 1) with parse errors, missing variable declarations, or type mismatches. inputs: all .tf files in the working directory. this is the cheapest check; run it in pre-commit hooks and at pipeline start.
plan the change. run terraform plan -out=tfplan to check what terraform will do without actually doing it. outputs: tfplan binary artifact (not human-readable; for apply) and a rendered plan text (stdout). inputs: current state (local or remote), current code (all .tf files), variable values (from -var, -var-file, *.auto.tfvars, terraform.tfvars, or TF_VAR_ env vars in that precedence order). cost: cloud api calls (describe instances, list security groups, etc.) to detect drift, plus validation (permissions, quotas, name collisions surfaces at apply, not plan).
read and gate the plan. examine the plan output for the destroy count and every "forces replacement" line (use terraform show tfplan to re-render if needed). cross-check against your intent: did you rename a resource (expect 0 to add, 0 to change, 0 to destroy if using moved block; else expect destroy + create)? did you add a subnet (expect 1 to add)? count matches? if destroy count exceeds destroy_gate threshold (config var, default 0), stop and name every destroyed address aloud before proceeding. for ci workflows, save terraform show -json tfplan to an artifact and parse it with a gate script (check destroy count, validate tag changes, etc.) before auto-approve happens. inputs: tfplan artifact, intent (from pr description, ticket, or runbook). outputs: approval decision (proceed to step 5) or rollback (discard tfplan, edit code, replan).
apply the saved plan. run terraform apply tfplan (with the file arg, not -auto-approve). outputs: state file updated (local or pushed to remote backend), cloud resources created/updated/destroyed, console output showing the apply transcript. inputs: tfplan artifact from step 3 (must be no older than ~30min to avoid state-moved-between-plan-and-apply errors; if suspect, discard and replan). this is the only apply you should run outside of ci; in ci, apply happens only after a human approves a plan in a pr, applying the saved plan on merge.
observe the outcome. check: (a) apply succeeded (exit 0) with the resources you intended; (b) no unexpected destroys happened; (c) no "saved plan is stale" error (which means state moved between plan and apply, requiring step 3 redone); (d) cloud console and terraform state list agree on what's managed. inputs: apply output, cloud provider console, terraform state show <address>. outputs: confidence the infrastructure is now in the declared state.
refactoring workflow: moved, import, removed
rename or move a resource (terraform >=1.1). add a moved block in the module where the resource lives:
moved {
from = aws_instance.old_name
to = aws_instance.new_name
}
run terraform plan; expect 0 to add, 0 to change, 0 to destroy. the block records the intent in code (reviewable, replayable across environments). apply. in a later pr, delete the moved block. if you skip the moved block and just rename the resource, terraform destroys the old and creates the new (downtime, new id). inputs: old resource address and new address. outputs: state updated with new address, cloud resource untouched, moved block in the code.
import an existing cloud object into state (terraform >=1.5). add an import block in the module:
import {
id = "i-0123456789abcdef0"
}
resource "aws_instance" "imported" {
# leave empty, or fill from terraform plan -generate-config-out
}
run terraform plan -generate-config-out=gen.tf; terraform fetches the object from aws and writes a draft hcl to gen.tf. copy the generated block into your module, edit it to match your naming and tag standards, and rerun plan. expect 0 to add, 0 to change, 0 to destroy. apply. delete the import block in a later pr. inputs: cloud resource id (instance id, sg name, etc.), cloud credentials, knowledge of the resource type. outputs: resource now in state and in code, with a zero-diff plan.
stop managing a resource without destroying it (terraform >=1.7). add a removed block:
removed {
from = aws_instance.legacy
destroy = false
}
run terraform plan; expect the resource to be removed from state (but not destroyed in aws). apply. inputs: resource address you want to stop tracking. outputs: state cleaned up, cloud resource still running (you'll manage it manually or with another tool).
debugging workflow: symptom to root cause
plan or apply fails with an error. run terraform plan -json | jq to see the error in json; or rerun with TF_LOG=debug terraform plan 2>&1 | grep -i error to find the line. common failures: (a) authentication (aws creds expired, gcp key unreachable, azure role missing permissions); (b) validation (variable type mismatch, missing required variable, for_each key not known at plan time); (c) provider bug (api endpoint changed, provider version incompatibility); (d) state corruption (serial/lineage mismatch, or the backend lost connectivity mid-apply). inputs: error message, current code, current state. outputs: root cause identified, fix proposed (retry auth, fix variable, downgrade provider, recover state, etc.).
plan shows a permanent diff every run ("permanent diff trap"). the resource diffs even though you didn't edit the code. root causes: (a) the cloud object is drifting (autoscaler, console edit, another pipeline); (b) the provider normalizes the value differently on each read (whitespace, sort order, api quirk); (c) you're using ignore_changes incorrectly (freezing the whole resource instead of one attribute). run terraform apply -refresh-only to pull the latest state from the cloud; if the diff persists, find who's writing to the cloud (autoscaler policy, console, another team's pipeline). do not reach for ignore_changes until you've proven the write source. if you must ignore one attribute (e.g., tags are written by a tagging automation), use ignore_changes = ["tags"] with a comment explaining why. inputs: plan output, cloud console, git history of edits to that resource block. outputs: diff explained, either root cause fixed or ignore_changes added with justification.
for_each fails with "invalid for_each argument". the key is built from a value not known at plan time (e.g., resource id, output from another resource). terraform cannot decide which instances to create until apply runs, so it errors. fix: key on stable, human-chosen strings (environment names, logical roles). example:
for_each = {
prod = { region = "us-east-1" }
dev = { region = "us-west-2" }
}
not:
for_each = aws_instance.base[*].id # fails: ids not known at plan time
inputs: for_each expression, resource attributes. outputs: for_each keyed on plan-time-known values, plan succeeds.
state lock is stuck (force-unlock). another process is applying and holding the lock; if that process is dead, the lock never clears. run terraform force-unlock <LOCK_ID> (find the lock id in the error message or in your backend storage). prove the holder is dead (check ci logs, kill the hanging process, verify the runner is offline) before unlocking, else you'll orphan a half-applied state. inputs: lock id, confidence that the holder process is dead. outputs: lock released, next plan/apply can proceed.
state is lost, corrupted, or wrong version pushed. if using s3 backend, check aws s3api list-object-versions --bucket <bucket> --prefix <key> for prior versions; restore with aws s3api get-object --bucket <bucket> --key <key> --version-id <vid> backup.tfstate && terraform state push backup.tfstate. if there are no prior versions or the corruption is older than retention, rebuild by import (step 8) for each resource, or restore from iac-as-code (rebuild the objects from scratch via a separate terraform apply, then import). inputs: backup state file (or prior version from s3/gcs/azurerm versions), knowledge of what's in the cloud. outputs: state file recovered or rebuilt, next plan shows 0 to add/change/destroy.
provider and version management
main.tf, declare required_providers:required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
run terraform init or terraform providers lock -platform=linux_amd64 -platform=darwin_arm64 (platforms from lock_platforms config). commit .terraform.lock.hcl to git. every ci job runs terraform init with the lock already present, so it uses locked versions. inputs: provider source, version constraint. outputs: .terraform.lock.hcl locked to specific versions per platform, preventing ci breakage on release day.
terraform plan and name every "forces replacement" and diff against the upgrade guide to confirm they're expected. if unexpected, downgrade and file a provider bug. once confident, apply to each environment in order (dev, staging, prod). inputs: upgrade guide, sandbox stack, provider version constraint. outputs: provider upgraded, all stacks validated, no surprises in prod.ci integration: plan-on-pr, apply-on-merge
terraform plan -out=tfplan -json in the stack directory; save the plan artifact and the json output; (b) post a comment on the pr with the plan summary (render from the json, or use terraform show tfplan); (c) gate the comment with a destroy-count check (if destroys > destroy_gate, post a warning and skip auto-approve); (d) on pr merge, run terraform apply <saved-tfplan>. inputs: pr event, branch push event, ci credentials (oidc role for aws, workload identity for gcp, etc.), saved plan artifact. outputs: plan comment on pr, apply run on merge, audit trail in ci logs.if a terraform command fails with "invalid backend config": the backend block has hardcoded values or typos. either inline the values in main.tf, or pass partial config via -backend-config flags (e.g., terraform init -backend-config=bucket=my-bucket -backend-config=key=prod/terraform.tfstate). avoid interpolation in the backend block; backends are evaluated before anything else.
if a variable value is ignored or an old value keeps winning: check precedence: -var and -var-file flags trump *.auto.tfvars (alphabetical order if multiple), which trump terraform.tfvars, which trump TF_VAR_ env vars, which trump the declared default. run terraform plan -var=key=value to force an override for one plan.
if for_each or count key changes would destroy and recreate a resource: use a moved block (terraform >=1.1) to preserve the resource across the key/index change. without it, every instance is destroyed then created (downtime, new ids, potential data loss).
if you have two regions or two accounts in one config: declare provider aliases (primary and secondary), then pass explicit providers maps into modules. do not abuse default_tags to work around missing provider aliasing.
if plan takes minutes: run with -refresh=false while iterating (skips cloud api calls), then split your state by blast radius (e.g., networking state separate from application state) to reduce per-plan time.
if you see "rate exceeded" or "throttling" during apply: lower -parallelism from the default 10 to 5 or 3. terraform will apply changes serially, slower but without slamming the provider's api.
if a secret landed in state, a plan file, or a log: rotate the secret first (it's already compromised). then remember: state is plaintext in the file, on disk, and in old backend versions. use sensitive = true to mask cli output, but the value still sits in state. prefer ephemeral values (terraform >=1.10) and write-only arguments (terraform >=1.11) for credentials. encrypt the backend (s3 sse, gcs customer-managed keys, azurerm storage service encryption) and restrict who can read state.
if you're choosing between terraform and opentofu: opentofu is a drop-in fork as of 1.6. decide once per organization. teams already on a hashicorp managed platform (tfc, tfe) or needing features that landed after the fork (1.7+) pay a migration cost. teams with licensing concerns or needing state encryption benefit from opentofu. after fork (1.7+), opentofu and terraform diverge on version floors; check tofu version against the opentofu changelog before using a newer block (moved, import, removed, etc.).
**if the saved plan is