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

First rule of each section in terraform.mdc, quoted as shipped.

Mistakes these rules are written to prevent

Source: “Pitfalls to avoid”.

Checks the agent runs before it says done

Example commands from the Terraform file (swap in your repo's own):

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

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.

Related stacks

← All stacks