Skip to content

Infrastructure as Code Patterns

Infrastructure as code patterns for AI agents are four recipes (split a Terraform monolith, gate the plan, adopt drift, harden CloudFormation or Pulumi) run by Claude Code, Codex or Cursor under plan-only cloud credentials. A machine-checked plan gate, not a human reading HCL, blocks every delete or replace until a named owner approves it.

This page is for developers who own a service’s infrastructure and tech leads who decide what an agent may touch in a cloud account. You inherit a Terraform repository with one 900-line main.tf, hardcoded CIDR blocks, a provider pinned two majors back, and a terraform plan that wants to replace the production RDS instance. The agent can split that file in minutes. The question is how you know the split destroys nothing, when the plan is 400 lines long and nobody wants to read it.

What you’ll walk away with from these infrastructure recipes

Section titled “What you’ll walk away with from these infrastructure recipes”
  • A permission setup in which the agent can run terraform plan but has no credentials that could apply
  • A short plan-gate.sh that fails on any delete or replace, so review starts from a short list instead of the whole plan
  • Copy-paste prompts for a module split with moved blocks, a plan review, drift adoption with import blocks, and a CloudFormation hardening pass
  • A table of gates (format, lint, policy, test, plan) that proves the change without reading every line
  • Recovery steps for stuck locks, drift, accidental replacements, provider major upgrades and a stuck CloudFormation rollback

The control that holds is the credential, not the prompt. An agent that has only a plan role cannot apply, whatever it decides to run. Set this up once per repository, in this order.

  1. Create a plan-only identity. On AWS, attach ReadOnlyAccess plus access to the state bucket: read on this stack’s state key, and s3:PutObject / s3:DeleteObject on the *.tflock key only. That is enough for terraform plan with S3 native locking. ReadOnlyAccess also grants s3:GetObject on every bucket in the account, including other stacks’ state files, so add an explicit Deny on s3:GetObject for any state key that is not this stack’s. Apply runs in CI under a different role that the agent never sees. The same split works on Azure (Reader plus the state storage account) and Google Cloud (Viewer plus the state bucket). Cloud MCP servers covers read-only identities per cloud.

  2. Deny the destructive commands in the tool. This is a typo-catcher on top of the credential, not a replacement for it.

    In .claude/settings.json (committed, so the whole team gets it):

    {
    "permissions": {
    "allow": ["Bash(terraform fmt *)", "Bash(terraform validate *)",
    "Bash(terraform plan *)", "Bash(terraform show *)", "Bash(tflint *)"],
    "ask": ["Bash(terraform state *)", "Bash(terraform import *)"],
    "deny": ["Bash(terraform apply *)", "Bash(terraform destroy *)",
    "Bash(terraform force-unlock *)", "Bash(terraform state rm *)"]
    }
    }

    Deny rules are evaluated before ask and allow. They match the command string, so sh -c 'terraform apply' is a different spelling: see permissions and sandboxing for why the sandbox and the credential are the real boundary.

  3. Write the conventions down once. Put them in AGENTS.md (Codex reads it; Claude Code reads it when the repository has no CLAUDE.md, from v2.1.277 on the latest channel) or CLAUDE.md, and in a Cursor project rule:

    ## Infrastructure rules
    - Terraform >= 1.11. S3 backend with `use_lockfile = true`. No new DynamoDB lock tables.
    - Pin providers to the current major (`~> 6.0` for hashicorp/aws) and commit .terraform.lock.hcl.
    - Renames and module moves use `moved` blocks. Never `terraform state mv` in a PR.
    - Adopt existing resources with `import` blocks, not `terraform import`.
    - No hardcoded CIDRs, regions, account IDs or secrets: variables and a secrets manager.
    - You may run fmt, validate, tflint, plan and show. You never run apply or destroy.

Recipe 1: split a Terraform monolith without destroying anything

Section titled “Recipe 1: split a Terraform monolith without destroying anything”

A module split changes every resource address. Without moved blocks, Terraform reads that as “destroy the old address, create the new one”, which on a database means data loss. With them, the correct plan for a pure refactor is zero changes: that single number is your acceptance criterion.

Terminal window
# Terminal, repo root. Manual mode so every edit is shown before it lands
claude --permission-mode manual

Paste the prompt below. Claude Code edits the files, runs terraform plan and reads its output in the same session, so the “zero changes” loop closes without you copying anything.

If the plan still shows changes, the cause is almost always one of three things: a missing moved block, a default that differs from the old literal, or count turned into for_each (which changes the index from [0] to ["name"] and also needs a moved block). Ask the agent to name which one for each resource.

Recipe 2: turn the plan into a gate you don’t read line by line

Section titled “Recipe 2: turn the plan into a gate you don’t read line by line”

A 400-line plan is where review fails, because the one -/+ on a database hides among 60 tag updates. Reverse the order: a script extracts the destructive actions from the machine-readable plan, and a human reviews only that list.

#!/usr/bin/env bash
# scripts/plan-gate.sh: exit 2 when the plan deletes or replaces anything
set -euo pipefail
terraform plan -out=tfplan -input=false -lock-timeout=60s
terraform show -json tfplan > plan.json
jq -r '.resource_changes[]?
| select(.change.actions | index("delete"))
| "\(.change.actions | join("/"))\t\(.address)"' plan.json > destructive.txt
jq '[.resource_changes[]? | {address, actions: .change.actions,
replace_paths: .change.replace_paths}]' plan.json > plan-summary.json
jq -r '[.resource_changes[]?.change.actions | join("/")] | group_by(.)
| map("\(.[0]): \(length)") | .[]' plan.json
if [ -s destructive.txt ]; then
echo "Delete or replace needs a named approver:"; cat destructive.txt; exit 2
fi

In terraform show -json, a replacement appears as ["delete","create"] or ["create","delete"], so matching on delete catches both. Run the script in the agent session and again in CI. In CI, write destructive.txt as a job output; when it is non-empty, the apply job targets a protected environment whose required reviewer is the service owner, and apply runs only from the saved tfplan that was reviewed.

Add lifecycle { prevent_destroy = true } to stateful resources (databases, buckets holding data, KMS keys) as part of Recipe 1. Terraform then refuses any plan that destroys them, which turns the most expensive mistake into a plan error.

Recipe 3: adopt console drift with import blocks

Section titled “Recipe 3: adopt console drift with import blocks”

Someone added a security group rule in the console during an incident, and now every plan wants to remove it. Do not let the agent overwrite the drift with an apply: the rule may be what is holding the incident back.

Run terraform plan -refresh-only to see exactly what changed outside Terraform. Then decide per resource: adopt it, revert it in the console, or change the configuration to match. For adoption, Terraform 1.5 and later write the import as code, so it is reviewed like any other change.

A cloud MCP server makes this faster: with read access, the agent lists the real resources itself instead of asking you to paste console output. Connect it read-only, as cloud MCP servers shows.

Recipe 4: harden a CloudFormation template or Pulumi stack

Section titled “Recipe 4: harden a CloudFormation template or Pulumi stack”

The same discipline applies with a different preview command. For CloudFormation, aws cloudformation deploy --no-execute-changeset creates a change set the agent can read without executing it. For Pulumi, pulumi preview --diff is the plan for a human, and pulumi preview --json is the one for the gate: filter the steps whose op is delete, replace, create-replacement or delete-replaced into destructive.txt, the same way plan-gate.sh does. The gate is the same: list replacements, block until an owner approves.

How do you verify agent-written infrastructure without reading every line?

Section titled “How do you verify agent-written infrastructure without reading every line?”

Stack the checks so each one catches a class of error mechanically, and a human looks only at what survives. Run them in this order, cheapest first, in the agent session and again in CI.

GateCommandCatchesFails the build when
Format and syntaxterraform fmt -check -recursive and terraform validateInvented arguments, type errors, bad referencesany error
Linttflint --recursiveInvalid instance types, deprecated syntax, unused declarationsany error
Static policycheckov -d .Public buckets, unencrypted storage, open security groupsa failed check not on the reviewed skip list
Behaviourterraform testA module that no longer does what its contract saysany failed run
Plan policyconftest test plan.jsonYour own rules applied to the actual planned valuesany deny
Destructionscripts/plan-gate.shDeletes and replacementsany line in destructive.txt

Versions checked on 2026-09-26: Terraform 1.16.4, hashicorp/aws provider 6.66.0, tflint 0.64.0, checkov 3.3.19, conftest 0.70.1. A plan policy for conftest is a few lines of Rego over plan.json:

policy/no_public_ssh.rego
package main
import rego.v1
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_security_group_rule"
rc.change.after.type == "ingress"
"0.0.0.0/0" in rc.change.after.cidr_blocks
rc.change.after.from_port <= 22
rc.change.after.to_port >= 22
msg := sprintf("%s opens SSH to the internet", [rc.address])
}

Who signs off. The agent may declare the checks green. It may not approve. A pull request that passes every gate and shows zero lines in destructive.txt needs one reviewer on the diff. A pull request with any destructive line needs the service owner to approve that list by name, and apply runs from the reviewed tfplan in CI. Attach the gate output to the pull request as its evidence bundle, so the reviewer reads six results and a short list instead of the HCL.

Ground the agent in the registry, not its memory

Section titled “Ground the agent in the registry, not its memory”

A common source of broken HCL is provider arguments the agent remembers from an older major version. Two additions fix that, and the full setup lives on infrastructure MCP servers.

HashiCorp’s Terraform MCP server with no token registers only its registry tools, which look up current provider and module documentation. That is read-only by construction:

Terminal window
claude mcp add terraform -s project -- docker run -i --rm hashicorp/terraform-mcp-server:1.3.0

HashiCorp’s agent skills (hashicorp/agent-skills, MPL-2.0) carry the conventions: refactor-module for Recipe 1, terraform-search-import for Recipe 3, terraform-test and terraform-style-guide for the gates. Install the plugin in Claude Code, or individual skills with the skills CLI (1.7.0) for Codex and Cursor:

Terminal window
# Claude Code
claude plugin marketplace add hashicorp/agent-skills
claude plugin install terraform@hashicorp
# Codex, Cursor and other agents: pick skills one at a time
npx skills add hashicorp/agent-skills/plugins/terraform/skills/refactor-module

The whole plugin loads 16 skills and costs about 2,150 always-on tokens in every session (claude plugin details terraform@hashicorp, 2026-09-26); each skill adds 0.5k–7k more when it fires. For the wider field, see infrastructure MCP servers. Do not also install terraform@claude-plugins-official: it registers a second terraform MCP server pinned to the older 0.4.0 image. Use a skill when you want conventions, and the MCP server when the agent needs live registry data.

Where to go next with infrastructure as code

Section titled “Where to go next with infrastructure as code”

The prerequisite for these recipes is a harness that holds: permissions and sandboxing. The next step is putting the gates and the apply step in a pipeline.