terraform
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 pl…
Iván
@ivangdavila
What This Skill Does
Writes, debugs, and refactors Terraform and OpenTofu configurations — HCL, modules, state, providers, and CI/CD pipelines. Handles plan/apply failures, state surgery, drift detection, provider pinning, and infrastructure-as-code review.
Replaces manual state manipulation and ad-hoc debugging by providing structured guidance for every Terraform failure mode, refactoring pattern, and CI integration.
When to Use It
- Debug a terraform plan that shows a permanent diff or unexpected destroy
- Rename a resource or module and update state without destroying infrastructure
- Import an existing cloud resource into Terraform state
- Recover from a stuck state lock or corrupted state file
- Pin provider versions or upgrade across a major provider version
- Design a CI pipeline with plan-on-PR, apply-on-merge, and drift detection
Install
$ openclaw skills install @ivangdavila/terraformUser 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.
Related skills
AWS | Amazon Web Services
@ivangdavilaArchitects, debugs, secures, and cost-optimizes AWS infrastructure — EC2, Lambda, RDS, VPC, IAM, ECS, CloudFront. Use when deploying or reviewing anything on AWS, when a bill jumps or spend has to come down, when an AccessDenied, throttle, timeout, 502/503/504, or unreachable-database error has no obvious cause, when choosing between Lambda, Fargate, EC2, RDS, DynamoDB, SQS, or EventBridge, when hardening IAM policies, S3 exposure, security groups, or secrets, when writing Terraform/CloudFormation/CDK against AWS, when auditing an account you inherited, or when a service quota, cold start, connection limit, or failover is the thing that broke. Covers VPC and subnet design, NAT versus VPC endpoints, Organizations and cross-account roles, backups and disaster recovery, and CLI/SSO profiles. Not for object-storage patterns in depth (`s3`), DynamoDB key modeling (`dynamodb`), Kubernetes manifest authoring (`k8s`), or Terraform language mechanics (`terraform`).
bash
@ivangdavilaWrites, debugs, and hardens Bash shell scripts — quoting, arrays, strict mode, traps, argument parsing, and macOS/Linux portability. Use when writing or reviewing any script, one-liner, cron job, deploy script, container entrypoint, or CI step; when a script breaks on spaces in filenames, exits silently, ignores set -e, hangs, or returns the wrong exit code; when quoting, IFS, globs, arrays, heredocs, process substitution, trap, getopts, or mapfile misbehave; when shellcheck flags SC2086 and friends; when unbound variable, bad substitution, command not found, ambiguous redirect, or unexpected end of file show up; when a script works by hand but fails under cron, systemd, sudo, or a CI runner; or when porting between macOS bash 3.2, GNU/Linux, WSL, and POSIX sh. Not for interactive zsh or fish configuration, not for PowerShell, and not for host-level cron, systemd, or permission failures (linux).
react
@ivangdavilaBuilds, debugs, and reviews React apps: components, hooks, state, Server Components, forms, performance, testing. Use when writing or refactoring components, choosing state management (useState, Context, Zustand, TanStack Query, Redux), when a component rerenders too often, loops infinitely ("too many re-renders"), shows stale or not-updating state, fails with a hydration mismatch or hook-order error, when an effect fires twice or fetches race, when typing lags or long lists scroll slowly, when adopting React 19, Server Actions, or the React Compiler, when testing components with Testing Library, typing props in TypeScript, or reviewing AI-generated React code. Not for React Native (mobile) or Next.js routing and deployment — use react-native or nextjs.
linux
@ivangdavilaDebugs and hardens Linux hosts: permissions, disk full, OOM kills, stuck processes, systemd units, cron, networking, SSH, and boot failures. Use when a service starts by hand but fails at boot, a process ignores kill -9, df and du disagree, a box runs out of memory or inodes, sudo or ACLs deny access, SELinux blocks a write, a job works in the shell but not in cron, sshd rejects a key, an upgrade leaves packages half-configured, load is high while the CPU sits idle, or a host needs firewall rules, users, LVM, journald, kernel tuning, or a security baseline. Covers Debian/Ubuntu, RHEL/Fedora, Arch, and Alpine. Not for shell-script syntax (bash) or container build and runtime internals (docker).
Kubernetes
@ivangdavilaDebugs Kubernetes workloads and reviews manifests: pods, probes, resources, rollouts, Services, storage, RBAC. Use when a pod is Pending, CrashLoopBackOff, ImagePullBackOff, OOMKilled or stuck Terminating, when a Service or Ingress serves nothing or returns 502/503/504, when cluster DNS is flaky, a rollout hangs or silently ships a broken version, an HPA refuses to scale, a PVC stays unbound, a node goes NotReady or a drain never finishes, when writing or reviewing YAML, Helm charts or kustomize overlays, when tuning requests, limits, QoS, probes and graceful shutdown, or when locking down RBAC, NetworkPolicy, Pod Security and Secrets. Covers kubectl triage, StatefulSets and PVCs, Jobs and CronJobs, autoscaling, admission webhooks, node drains and cluster upgrades. Not for building container images — that is `docker`.
MongoDB
@ivangdavilaDesigns MongoDB schemas, indexes, and aggregation pipelines, and debugs slow queries, connection errors, and replica set failures. Use when modeling documents, deciding embed vs reference, reading an explain plan, or fixing a COLLSCAN, and when a query times out, a pipeline aborts at the memory limit, a cursor dies mid-loop, the pool exhausts and server selection times out, writes fail with duplicate key or "not writable primary", a secondary lags, the oplog window closes, a shard key hotspots, or WiredTiger cache stalls the cluster. Covers mongosh and Compass, Atlas, Mongoose and driver connection strings, transactions and retry loops, change streams, time-series collections, Atlas Search and vector search, sharding, backups, restores, and upgrades. Not for SQL or relational modeling — normalization instincts actively mislead here.