# Infrastructure as code with AI agents

URL: https://developertoolkit.ai/en/workflows/ship/infrastructure-code/
Description: Let Claude Code, Codex or Cursor write Terraform, CloudFormation, CDK and Pulumi changes while CI proves them with gates, plans and drift checks.

Infrastructure as code with AI agents works when the agent writes and plans the change but never applies it. Claude Code, Codex or Cursor draft Terraform, CloudFormation, CDK or Pulumi from current provider schemas; deterministic gates, a machine-read plan and scheduled drift checks prove the result; and CI applies it after a named owner approves the plan.

You inherit a Terraform repository with no documentation, the platform engineer who wrote it has left, and you need a read replica in a second region by Friday. An agent can read the modules and write the change in minutes. The risk is everything around it: a provider argument invented from stale training data, a rename that the plan turns into a destroy, or an agent with your admin profile running `terraform apply` because a prompt said "deploy it".

This page is for developers who make infrastructure changes with an agent, and for tech leads who decide what the agent may touch.

## What you get from an agent-driven IaC workflow

- A split of work in which the agent writes and plans, CI applies, and a person approves a plan rather than reading every line of HCL.
- Guardrails for all three tools: a read-only cloud identity, repository instructions, and command rules that block `apply` and `destroy`.
- MCP and skill setup that grounds the agent in the current Terraform Registry and AWS IaC documentation, with the package names that are wrong.
- A gate stack per IaC tool (Terraform or OpenTofu, CloudFormation, CDK, Pulumi) that runs before anyone looks at the change.
- Four copy-paste prompts: a constraint brief, a gated change, a plan summary for the reviewer, and a drift triage.
- Recovery steps for state locks, provider-pin breakage, hidden replacements and drift.

## Who does what: the agent plans, CI applies

The agent's speed is only safe when the dangerous steps are out of its reach. Decide the split before the first prompt:

| Step | Agent | CI | Person |
| --- | --- | --- | --- |
| Read the repo, look up provider schemas | Yes | | |
| Write HCL, templates or CDK/Pulumi code | Yes | | |
| `fmt`, `validate`, lint, security scan, tests | Runs them locally | Runs them again as the gate | |
| `plan` / `preview` / `synth` | Locally, with a read-only identity | Authoritative plan on the pull request | |
| Summarise the plan and flag deletes | Yes | Fails the build on unapproved deletes | Checks the summary against the plan |
| `apply` / `up` / `deploy` | Never | After approval, from the saved plan | Approves; owns the rollback |
| State surgery (`force-unlock`, `state rm`) | Never without asking | | Decides |

The person who approves is the owner of the affected workspace or stack, not the author of the prompt. That approval covers the plan (what will change in the cloud), which is a smaller and more honest artifact than the diff.

## Set the guardrails before the first prompt

1. **Give the agent a read-only identity.** Create a cloud role or profile that can read resources and state but not write them, and make it the only credential in the environment where the agent runs. No production admin profile, no long-lived deploy key on the machine. For AWS, a role with the `ReadOnlyAccess` managed policy plus access to the state bucket is enough to plan (with `use_lockfile`, a plan also writes a lock object next to the state). [Cloud MCP servers with read-only identities](/en/ecosystem/mcp/cloud-platform-mcp/) covers the same setup for AWS, Azure and Google Cloud.

2. **Write the rules into the repository instructions.** Put them in `AGENTS.md` for Codex, in `CLAUDE.md` for Claude Code (which falls back to `AGENTS.md` only when no `CLAUDE.md` exists, from v2.1.277, on both release channels), and in a project rule under `.cursor/rules/` for Cursor ([Cursor project rules](/en/cursor-ide/quick-start/project-rules/) covers the file format and frontmatter):

   ```markdown
   ## Infrastructure rules
   - Never run terraform apply, destroy, import or force-unlock, pulumi up or destroy, or cdk deploy. CI applies.
   - Before writing a resource, look up its arguments for the provider version pinned in versions.tf.
   - Every change must pass: terraform fmt -check, terraform validate, tflint, trivy config, terraform test.
   - Renaming or moving a resource requires a moved block. A plan with an unexplained replace is a failed change.
   - No wildcard IAM actions, no 0.0.0.0/0 ingress, encryption at rest on every storage resource.
   ```

3. **Back the rules with command rules.** Instructions can be ignored; command rules cannot. The rules differ per tool:

   **Claude Code**

   In `.claude/settings.json`:

   ```json
   {
     "permissions": {
       "deny": [
         "Bash(terraform apply)",
         "Bash(terraform apply *)",
         "Bash(terraform destroy *)",
         "Bash(terraform import *)",
         "Bash(terraform force-unlock *)",
         "Bash(tofu apply)",
         "Bash(tofu apply *)",
         "Bash(tofu destroy *)",
         "Bash(tofu import *)",
         "Bash(tofu force-unlock *)",
         "Bash(pulumi up)",
         "Bash(pulumi up *)",
         "Bash(pulumi destroy *)",
         "Bash(cdk deploy)",
         "Bash(cdk deploy *)",
         "Bash(cdk destroy *)",
         "Bash(npx cdk deploy)",
         "Bash(npx cdk deploy *)",
         "Bash(npx cdk destroy *)"
       ],
       "ask": [
         "Bash(terraform state rm *)",
         "Bash(terraform state mv *)",
         "Bash(terraform state push *)",
         "Bash(tofu state rm *)",
         "Bash(tofu state mv *)",
         "Bash(tofu state push *)"
       ]
     }
   }
   ```

   The list names both the bare command (`terraform apply`) and the form with arguments (`terraform apply *`), so the rule does not depend on whether a trailing wildcard also matches a command with no arguments. The `ask` rules make Claude Code stop and ask before state surgery, as the Codex `prompt` rule below does.

   **Codex**

   In `.codex/rules/infra.rules`. The `match` and `not_match` examples are checked when the file loads. Codex loads project rules only in a trusted project, and `codex exec --ignore-rules` skips them, so keep that flag out of CI (checked in the codex-cli 0.157.1 source and `codex exec --help`):

   ```python
   prefix_rule(
       pattern = [["terraform", "tofu"], ["apply", "destroy", "import", "force-unlock"]],
       decision = "forbidden",
       justification = "CI applies from a reviewed plan, and a human handles imports and locks. Run terraform plan instead.",
       match = ["terraform apply tfplan", "tofu destroy", "terraform force-unlock 1a2b"],
       not_match = ["terraform plan -out tfplan", "terraform validate"],
   )

   prefix_rule(
       pattern = [["terraform", "tofu"], "state", ["rm", "mv", "push"]],
       decision = "prompt",
       justification = "State surgery needs a human. Say why before you run it.",
       match = ["terraform state rm aws_s3_bucket.logs"],
       not_match = ["terraform state list", "terraform state show aws_s3_bucket.logs"],
   )

   prefix_rule(
       pattern = ["pulumi", ["up", "destroy"]],
       decision = "forbidden",
       justification = "CI runs pulumi up from a reviewed preview.",
       match = ["pulumi up --yes"],
       not_match = ["pulumi preview"],
   )

   prefix_rule(
       pattern = ["cdk", ["deploy", "destroy"]],
       decision = "forbidden",
       justification = "CI deploys and destroys stacks. Run cdk synth or cdk diff instead.",
       match = ["cdk deploy --all", "cdk destroy MyStack"],
       not_match = ["cdk synth", "cdk diff"],
   )

   prefix_rule(
       pattern = ["npx", "cdk", ["deploy", "destroy"]],
       decision = "forbidden",
       justification = "CI deploys and destroys stacks. Run npx cdk synth or npx cdk diff instead.",
       match = ["npx cdk deploy --all", "npx cdk destroy MyStack"],
       not_match = ["npx cdk synth", "npx cdk diff"],
   )
   ```

   Check a command without starting a session: `codex execpolicy check --rules .codex/rules/infra.rules terraform apply tfplan` returns `"decision":"forbidden"` (tested on codex-cli 0.157.1).

   **Cursor**

   Cursor's Run modes decide which terminal commands wait for your approval, and they live in each developer's settings, not in the repo ([permissions, sandboxes and approval modes](/en/workflows/harness/permissions-and-sandboxing/) covers them). Cursor's Hooks can observe and block stages of the agent loop (see Cursor's [hooks documentation](https://cursor.com/docs/hooks)), so a project hook can refuse `terraform apply`. Check the shell-execution event name in the current docs before you rely on it. Either way, the read-only identity from step 1 is the control: keep it as the only cloud credential in the shell Cursor starts from.

In Codex (checked on codex-cli 0.157.1), a prefix rule matches the command text, so `sh -c 'terraform apply'`, `terraform -chdir=envs/prod apply`, `pnpm exec cdk deploy` or a wrapper script is a spelling it does not see. Treat Claude Code deny rules the same way: they catch honest mistakes and typos. The read-only identity in step 1 is the control that holds.

## Ground the agent in current provider schemas

Models write provider arguments from training data, which lags the providers. The AWS provider for Terraform was on the 6.x line in September 2026, per its [changelog](https://github.com/hashicorp/terraform-provider-aws/blob/main/CHANGELOG.md); a model that emits `~> 5.0` is guessing. An MCP server that reads the registry, plus the vendor's skills, fixes most of that.

| Stack | Ground it with | What it adds |
| --- | --- | --- |
| Terraform / OpenTofu | HashiCorp Terraform MCP server (Docker image `hashicorp/terraform-mcp-server`) | Registry lookups: provider versions, resource docs, modules. HCP Terraform workspaces with a token |
| Terraform style | HashiCorp skills (`hashicorp/agent-skills`) | `terraform-style-guide`, `terraform-test`, `refactor-module`, `terraform-search-import` |
| CloudFormation / CDK | `awslabs.aws-iac-mcp-server` (PyPI) | `cfn-lint` validation, `cfn-guard` compliance checks, failed-stack troubleshooting, CDK docs and samples |
| Pulumi | `@pulumi/mcp-server` (npm 0.2.0, last published 2025-09) | Registry lookups, `preview`, `refresh`. It also exposes `pulumi-cli-up` and `deploy-to-aws` (which returns instructions that steer the agent into provisioning AWS resources; both checked in the 0.2.0 package), and shell command rules catch neither: deny the tools themselves (in Claude Code, add `mcp__pulumi__pulumi-cli-up` and `mcp__pulumi__deploy-to-aws` to `permissions.deny`, assuming you registered the server as `pulumi`, for example with `claude mcp add pulumi -- npx @pulumi/mcp-server@0.2.0 stdio`; the prefix is `mcp__<server-name>__`) or leave the server unregistered, and rely on the read-only identity, which blocks it either way |
| Live AWS reads | Agent Toolkit for AWS | Account reads through the managed AWS MCP Server |

Register the two servers most IaC work needs:

**Claude Code**

```bash
# Terminal, repo root. Registry-only Terraform server, pinned image
claude mcp add terraform -s project -- docker run -i --rm hashicorp/terraform-mcp-server:1.3.0

# CloudFormation and CDK validation; credentials from the read-only profile
claude mcp add aws-iac -s project -e AWS_PROFILE=agent-readonly -- uvx awslabs.aws-iac-mcp-server@latest

claude mcp list
```

**Codex**

```bash
codex mcp add terraform -- docker run -i --rm hashicorp/terraform-mcp-server:1.3.0
codex mcp add aws-iac --env AWS_PROFILE=agent-readonly -- uvx awslabs.aws-iac-mcp-server@latest
codex mcp list
```
Both entries land under `[mcp_servers.*]` in `~/.codex/config.toml`.

**Cursor**

In `.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "terraform": {
      "command": "docker",
      "args": ["run", "-i", "--rm", "hashicorp/terraform-mcp-server:1.3.0"]
    },
    "aws-iac": {
      "command": "uvx",
      "args": ["awslabs.aws-iac-mcp-server@latest"],
      "env": { "AWS_PROFILE": "agent-readonly" }
    }
  }
}
```

Add HashiCorp's skills with `claude plugin marketplace add hashicorp/agent-skills` then `claude plugin install terraform@hashicorp` in Claude Code, or one at a time with `npx skills add hashicorp/agent-skills/plugins/terraform/skills/terraform-style-guide` elsewhere. The HCP Terraform token setup, tool allowlists and the context cost of each server are on [infrastructure MCP: Docker, Kubernetes and Terraform](/en/ecosystem/mcp/infrastructure-mcp/).

**Package names that look right and are wrong (checked 2026-09-26)**

- npm `terraform-mcp-server` is a community package (maintainer thrashr888, last published 2025-03), not HashiCorp's. HashiCorp ships the Docker image `hashicorp/terraform-mcp-server`.
- `awslabs.terraform-mcp-server`, `awslabs.cdk-mcp-server` and `awslabs.cfn-mcp-server` carry deprecation notices on PyPI. The first points to HashiCorp's server; the other two point to `awslabs.aws-iac-mcp-server`.
- `awslabs.aws-api-mcp-server` says it is superseded by the AWS MCP Server in the Agent Toolkit for AWS.
- `terraform@claude-plugins-official` is the MCP server pinned to image 0.4.0; `terraform@hashicorp` is the skills bundle with no server.

## Brief the change as constraints, not resources

"Create an EKS cluster with three nodes" gets a generic cluster. Describe the business constraints and ask the agent to name its assumptions before it writes code. This prompt works the same in all three tools.

**Copy-paste prompt: constraint brief before any HCL**

```text
Read this repository's Terraform (modules/ and envs/) and versions.tf first. Do not write code yet.

I need a Postgres read replica of the orders database in eu-west-1 for disaster recovery. Constraints: RPO under 5 minutes, the replica must not be reachable from the internet, it must use the existing KMS key pattern in modules/kms, and the added cost must stay under $600/month.

Reply with:
1. The modules and resources you would add or change, with file paths.
2. The provider arguments you will use, looked up with the terraform MCP server for the aws provider version pinned in versions.tf.
3. The trade-offs you made to meet the budget.
4. The three riskiest assumptions I must confirm before you write HCL.
5. Anything in the current plan that would be replaced or destroyed by this change.
```

Item 4 is the one that saves the week. It surfaces where the agent is guessing (is the source database encrypted with a key that can be shared cross-region? is the budget per month or per environment?) while correcting course costs one message. Item 5 forces the agent to think about the blast radius before it touches anything.

## Generate the change behind a gate stack

Let the agent write the change and run every deterministic check until they pass. The exit codes decide; the agent's report of them does not.

| Stack | Gates the agent runs and CI repeats |
| --- | --- |
| Terraform | `terraform fmt -check -recursive` · `terraform validate` · `tflint --recursive` · `trivy config .` or `checkov -d .` · `terraform test` |
| OpenTofu | The same with `tofu` in place of `terraform` |
| CloudFormation | `cfn-lint template.yaml` · `cfn-guard validate -d template.yaml -r rules/` |
| CDK | `cdk synth` with `cdk-nag` registered, so security findings fail the synth |
| Pulumi | `pulumi preview --policy-pack ./policy` with a CrossGuard policy pack |

For CDK, `cdk-nag` 3.x registers as a validation plugin: `Validations.of(app).addPlugins(new AwsSolutionsChecks(app));` in the app entry point.

A grounded Terraform root config pins what models most often get wrong:

```hcl
terraform {
  required_version = ">= 1.11"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }

  backend "s3" {
    bucket       = "acme-tf-state-prod"
    key          = "platform/terraform.tfstate"
    region       = "us-east-1"
    encrypt      = true
    use_lockfile = true # S3-native locking; a new project does not need a DynamoDB lock table
  }
}
```

**Copy-paste prompt: write the change and pass the gates**

```text
Implement the read replica we agreed, following the terraform-style-guide skill. Put it in modules/database as a variable-driven block and wire it in envs/prod.

Add a terraform test in modules/database/tests/ that asserts, from a plan: the replica is in eu-west-1, publicly_accessible is false, storage_encrypted is true, and kms_key_id comes from the kms module.

Run terraform fmt -check -recursive, terraform validate, tflint --recursive, trivy config . and terraform test. Fix until all five exit 0, then run terraform plan -out tfplan with the read-only profile.
Do not run terraform apply. Finish with the exact commands you ran and their exit codes.
```

Security scanners over-flag (a WAF demanded on an internal load balancer) and the agent under-flags (a wildcard IAM action it wrote itself). Keep the scanner as the gate, and handle each suppression as a reviewed line in the scanner's config with a reason, never as a silent skip the agent adds to make the build green.

## Make the plan the review artifact

The reviewer does not read the HCL line by line. The reviewer reads the plan, which says what will happen in the cloud, and CI makes sure the plan contains no surprise the reviewer did not approve.

1. **Save the plan as JSON in CI.** The job runs with a plan-only role that reaches nothing beyond reading state and resources and writing the lock object:

   ```bash
   terraform plan -out tfplan
   terraform show -json tfplan > plan.json
   ```

   A plan executes code from the pull request: provider binaries, `data "external"` programs and module sources. So run the credentialed plan job only for branches in the same repository, on the `pull_request` trigger, which gives forks no secrets. Never use `pull_request_target` to check out the pull request head. Plan a contributor's fork only after a maintainer has read the change and pushed it to a branch in this repository, or not at all: a label that starts a credentialed plan of the fork's head runs the fork's code with the plan role.

2. **Fail the build on any delete or replace that is not declared.** Replacements show up as a `delete` plus a `create` in `resource_changes`:

   ```bash
   jq -r '.resource_changes[] | select(any(.change.actions[]; . == "delete")) | .address' plan.json \
     > deletes.txt
   if [ -s deletes.txt ] && ! grep -q '^allow-delete:' pr-body.txt; then
     echo "Plan deletes or replaces:"; cat deletes.txt; exit 1
   fi
   ```

   `pr-body.txt` is the pull request description, written to a file by an earlier step. The author opts in with an `allow-delete:` line that names the addresses and the reason. When an agent opened the pull request, it wrote that description too, so the line is a declaration, not a control: the check only makes a delete visible, and the workspace owner's required approval of the plan is what stops it. The owner compares every address in `deletes.txt` with the `allow-delete:` line before approving. For richer rules (tags, instance sizes, regions) run a policy engine such as Conftest over the same `plan.json`.

3. **Have the agent summarise the plan for the reviewer.** The summary is a map, not the evidence: the reviewer checks it against the plan output.

   **Copy-paste prompt: plan summary for the pull request**

   ```text
   Read plan.json (terraform show -json output). Do not run any terraform command.

   Produce a pull request comment with:
   - Counts of create, update, delete and replace actions.
   - Every delete or replace: resource address, and the attribute that forces the replacement (from replace_paths).
   - Every change to IAM, security groups, network ACLs, KMS keys or public-access settings, with before and after values.
   - Anything that is not explained by the task in the pull request description.
   Quote resource addresses exactly as they appear in plan.json so a reviewer can search for them.
   ```

4. **The workspace owner approves, CI applies the saved plan.** CI runs `terraform apply tfplan` from the same plan file, so what was approved is what runs. Rollback is a revert of the pull request followed by the same pipeline; state operations stay with the owner.

This is the infrastructure version of the [evidence bundle an agent's pull request must carry](/en/workflows/ship/evidence-bundle/): gate exit codes, the saved plan, the delete check and the summary. How a reviewer triages that bundle without reading every line is on [reviewing an agent's pull request](/en/workflows/ship/agent-pr-review/), and wiring the jobs into a pipeline is on [CI/CD pipelines with AI](/en/workflows/ship/ci-cd-pipelines/).

## Detect drift on a schedule and triage it with the agent

Drift (a console hotfix, a manual security-group rule) is where infrastructure code quietly stops being the truth. Detect it with the IaC tool itself on a schedule, then give the agent the output to triage. The detection is deterministic; the agent only ranks and explains.

**Terraform**

```bash
# Exit code 2 means the real infrastructure differs from state
terraform plan -refresh-only -detailed-exitcode -out drift.tfplan
terraform show -json drift.tfplan > drift.json
```

**CloudFormation**

```bash
ID=$(aws cloudformation detect-stack-drift --stack-name db-cluster-prod \
  --query StackDriftDetectionId --output text)
# Poll until DetectionStatus is DETECTION_COMPLETE
aws cloudformation describe-stack-drift-detection-status --stack-drift-detection-id "$ID"
aws cloudformation describe-stack-resource-drifts --stack-name db-cluster-prod \
  --stack-resource-drift-status-filters MODIFIED DELETED > drift.json
```

**Pulumi**

```bash
# Shows what a refresh would change in state, without writing it
pulumi refresh --preview-only --stack prod
```

**Copy-paste prompt: drift triage with remediation options**

```text
Read drift.json, the drift report for the prod stacks. Read the Terraform code for each drifted resource. Change nothing.

For each drifted resource give: the address, the property that changed, expected versus actual value, and the security or availability risk.
Then recommend one of: (a) revert, by re-applying the code through CI; (b) adopt the manual change into code, with the exact HCL diff; (c) ask the owner, when the change looks like a deliberate break-glass fix.
Rank by blast radius. Mark any drift on IAM, security groups, public access or encryption as URGENT.
Quote the report line each finding comes from.
```

Before you act on a finding, confirm two things the agent cannot know: that the "expected" value matches the code on the default branch, not an old plan, and that the manual change was not an undocumented fix someone made during an incident. Ask the stack owner; then the chosen remediation goes through the same pull request and plan review as any other change.

## What breaks when agents write infrastructure code?

**`terraform apply` in CI hangs on "Acquiring state lock".** Two runs overlapped, or an earlier run crashed mid-apply. Read the lock holder and time from the error first. Run `terraform force-unlock LOCK_ID` only after you confirm that run is dead; unlocking a live apply can corrupt state. Stop the overlap with a concurrency group on the apply job.

**The plan replaces a resource nobody meant to touch.** The agent renamed a resource or moved it into a module, so Terraform sees a delete and a create. The delete check in CI catches it. Ask the agent to add a `moved` block from the old address to the new one and re-plan until the replace disappears.

**`terraform init` fails after the agent changed the provider pin.** The lock file still pins the old major version. Do not let the agent bump a provider major version in the same change as a feature; do it in its own pull request after reading the provider's upgrade guide. Run `terraform init -upgrade` to update `.terraform.lock.hcl`, then `terraform providers lock -platform=linux_amd64 -platform=darwin_arm64` so CI and laptops share the same hashes.

**`terraform validate` rejects an argument the agent wrote.** The agent skipped the registry lookup or read the latest docs against an older pinned provider. Make the lookup a separate turn and name the pinned version in the prompt, as in the constraint brief above.

**The gates pass but the change is permissive.** Generated IAM policies drift toward wildcard actions and broad principals, which many scanners only warn about. Add a policy rule that fails the plan on `"Action": "*"` and on `0.0.0.0/0` ingress, and keep security-sensitive paths (IAM, networking, KMS) on a list that always needs the owner's approval.

**The MCP server returns nothing, or stale data.** Check the package first (see the caution box above: the npm `terraform-mcp-server` and the deprecated AWS Labs servers are common culprits), then the credentials: `AWS_PROFILE` and region in the server's `env`, and the profile's read permissions. `claude mcp list` or `codex mcp list` shows whether the server connected. More in [MCP connection issues](/en/ecosystem/mcp/mcp-connection-issues/).

**A blanket drift revert removes a real fix.** Someone opened a port during an incident and the revert closes it again. Never revert drift on a schedule without triage; route each finding through option (c) of the drift prompt when the change is recent.

**The agent follows an instruction hidden in plan output or a resource tag.** Plan output, tags and descriptions are data that reach the model. Keep the agent read-only while it reads them, and keep write-capable servers out of the same session. See [MCP security](/en/ecosystem/mcp/mcp-security/).

## Where to go next with infrastructure as code

- [Infrastructure MCP: Docker, Kubernetes and Terraform](/en/ecosystem/mcp/infrastructure-mcp/) — HCP Terraform tokens, tool allowlists and the context cost of each server.
- [CI/CD pipelines with AI](/en/workflows/ship/ci-cd-pipelines/) — Wire the gates, the saved plan and the delete check into a pipeline.
- [Progressive delivery for agent-written changes](/en/workflows/ship/progressive-delivery/) — Canaries and automatic rollback as the safety net after the apply.
- [Cloud cost management and FinOps](/en/workflows/operate/cloud-cost-optimization/) — Budgets, right-sizing and cost governance as reviewed Terraform.
- [Infrastructure as code patterns](/en/cookbook/devops-recipes/infrastructure-as-code/) — More prompts for modules, migrations and multi-environment layouts.
- [Security operations](/en/workflows/operate/security-operations/) — Continuous cloud security checks beyond the per-change gate.
