Terraform rules for AI coding agents
Terraform is the stack where an agent's mistake can delete real infrastructure, so these rules start with what the agent may run. Formatting, init -backend=false, validate and plan are allowed; apply, destroy, imports against shared state and state surgery are not. The rules apply to Terraform and OpenTofu code.
The rest is about writing code that plans the way you expect: pinned Terraform and provider versions with the lock file committed, a remote backend with locking and encryption, for_each over stable keys rather than count indexes that shift when an item is removed, moved blocks for refactors, typed and described variables, and IAM built from policy documents with no wildcard actions or resources without a written reason.
Folder: rules/terraform/ · terraform.mdc, CLAUDE.md, AGENTS.md
What the Terraform rules tell your agent
- What the agent may runRun
terraform fmt -recursive,terraform init -backend=false,terraform validateandterraform planfreely. Never runterraform apply,destroy,importagainst shared state,state rm/state mv, or anything with-auto-approve. A human applies. - Versions and providersPin Terraform with
required_versionand each provider inrequired_providerswith a pessimistic constraint (version = "~> 5.40"). Commit.terraform.lock.hcl. - StateUse a remote backend with locking and encryption. For the S3 backend on Terraform 1.10+,
use_lockfile = truereplaces the DynamoDB lock table. - Writing resourcesUse
for_eachwith a map or set of stable keys for collections.countindexes shift when an item is removed in the middle, which destroys and recreates the wrong resources. Keepcountfor on/off toggles (count = var.enabled ? 1 : 0). - Variables and outputsEvery variable has a
typeand adescription. Addvalidationblocks for values with known shapes (CIDRs, environment names) and defaults only where a default is safe.
First rule of each section in terraform.mdc, quoted as shipped.
Mistakes these rules are written to prevent
- Using
-targetas a normal workflow. It leaves state partially applied; reserve it for recovery. - Interpolation-only expressions such as
"${var.name}". Writevar.name. - Changing a resource attribute that forces replacement (a name, a subnet) without noticing it in the plan.
Source: “Pitfalls to avoid”.
Checks the agent runs before it says done
Example commands from the Terraform file (swap in your repo's own):
- Format:
terraform fmt -check -recursive - Validate:
terraform validate - Lint:
tflint(if configured) - Plan:
terraform plan(read-only; never runterraform apply)
Run terraform fmt -check -recursive, terraform validate, the scanner, and terraform plan for each changed root. Paste the plan summary line (Plan: X to add, Y to change, Z to destroy).
Before and after: Terraform: review a plan for destructive changes
Prompt card 34 · Stack: Terraform or OpenTofu · Type: Plan review and refactor · Best model: a frontier reasoning model
What the card is for
Read a terraform plan together with the code change that produced it, explain every replace and destroy, and rewrite the change (usually with moved blocks or for_each keys) so that refactors do not recreate live resources.
Worked example from the card
Before
# old
resource "aws_sqs_queue" "jobs" {
count = length(var.queues) # ["email", "billing", "export"]
name = "jobs-${var.queues[count.index]}"
}
# new: someone removed "email" from var.queues and switched to for_each
resource "aws_sqs_queue" "jobs" {
for_each = toset(var.queues) # ["billing", "export"]
name = "jobs-${each.key}"
}
Plan excerpt: aws_sqs_queue.jobs[0] will be destroyed, [1] and [2] destroyed, aws_sqs_queue.jobs["billing"] and ["export"] will be created.
Expected AI output (abridged)
Plan: 2 to add, 0 to change, 3 to destroy.
Cause: address change from index keys to string keys. Terraform sees jobs[1] and jobs[2]
as different resources from jobs["billing"] and jobs["export"]. The billing and export
queues would be deleted and recreated empty: messages in flight are lost, and consumers
pointing at the old queue URLs fail until redeployed.
Intended destroy: jobs[0] (email) only. Confirm the email queue is drained first.
Fix: moved blocks for billing and export.
After
resource "aws_sqs_queue" "jobs" {
for_each = toset(var.queues)
name = "jobs-${each.key}"
lifecycle { prevent_destroy = true }
}
moved {
from = aws_sqs_queue.jobs[1]
to = aws_sqs_queue.jobs["billing"]
}
moved {
from = aws_sqs_queue.jobs[2]
to = aws_sqs_queue.jobs["export"]
}
# jobs[0] (email) is intentionally removed. Because prevent_destroy is now set on the
# resource block, remove the email queue in a separate change first, or use a `removed`
# block (Terraform 1.7+) with lifecycle { destroy = false } to forget it without deleting.
Expected new plan: 0 to add, 0 to change, 1 to destroy (or 0 to destroy with the removed approach).
Excerpt from card 34; the prompt itself is in the pack.
More Terraform prompt cards in the pack
- Card 35: Terraform: IAM and network exposure review
Using these rules in Cursor, Claude Code and AGENTS.md tools
Cursor
Put _base.mdc and terraform.mdc in .cursor/rules/. Globs: main.tf, **/*.tf, **/*.tfvars, **/*.tftest.hcl.
Claude Code
Root CLAUDE.md, base first. Path-scoped variant .claude/rules/terraform.md for **/*.tf, **/*.tftest.hcl; PostToolUse hook: terraform fmt <file>.
AGENTS.md tools (Codex, Gemini CLI, Aider)
Base then stack in one AGENTS.md. Gemini CLI needs context.fileName; Aider needs --read AGENTS.md. Put this file in infra/ or each root module directory. Agents use the AGENTS.md nearest to the file being edited, so a nested copy overrides a root one for that subtree.