Infrastructure as code with AI agents
PLInfrastructure 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
Section titled “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
applyanddestroy. - 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
Section titled “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
Section titled “Set the guardrails before the first prompt”-
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
ReadOnlyAccessmanaged policy plus access to the state bucket is enough to plan (withuse_lockfile, a plan also writes a lock object next to the state). Cloud MCP servers with read-only identities covers the same setup for AWS, Azure and Google Cloud. -
Write the rules into the repository instructions. Put them in
AGENTS.mdfor Codex, inCLAUDE.mdfor Claude Code (which falls back toAGENTS.mdonly when noCLAUDE.mdexists, from v2.1.277, on both release channels), and in a project rule under.cursor/rules/for Cursor (Cursor project rules covers the file format and frontmatter):## 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. -
Back the rules with command rules. Instructions can be ignored; command rules cannot. The rules differ per tool:
In
.claude/settings.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. Theaskrules make Claude Code stop and ask before state surgery, as the Codexpromptrule below does.In
.codex/rules/infra.rules. Thematchandnot_matchexamples are checked when the file loads. Codex loads project rules only in a trusted project, andcodex exec --ignore-rulesskips them, so keep that flag out of CI (checked in the codex-cli 0.157.1 source andcodex exec --help):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 tfplanreturns"decision":"forbidden"(tested on codex-cli 0.157.1).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 covers them). Cursor’s Hooks can observe and block stages of the agent loop (see Cursor’s hooks documentation), 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.
Ground the agent in current provider schemas
Section titled “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; 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:
# Terminal, repo root. Registry-only Terraform server, pinned imageclaude 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 profileclaude mcp add aws-iac -s project -e AWS_PROFILE=agent-readonly -- uvx awslabs.aws-iac-mcp-server@latest
claude mcp listcodex mcp add terraform -- docker run -i --rm hashicorp/terraform-mcp-server:1.3.0codex mcp add aws-iac --env AWS_PROFILE=agent-readonly -- uvx awslabs.aws-iac-mcp-server@latestcodex mcp listBoth entries land under [mcp_servers.*] in ~/.codex/config.toml.
In .cursor/mcp.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.
Brief the change as constraints, not resources
Section titled “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.
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
Section titled “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:
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 }}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
Section titled “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.
-
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:
Terminal window terraform plan -out tfplanterraform show -json tfplan > plan.jsonA 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 thepull_requesttrigger, which gives forks no secrets. Never usepull_request_targetto 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. -
Fail the build on any delete or replace that is not declared. Replacements show up as a
deleteplus acreateinresource_changes:Terminal window jq -r '.resource_changes[] | select(any(.change.actions[]; . == "delete")) | .address' plan.json \> deletes.txtif [ -s deletes.txt ] && ! grep -q '^allow-delete:' pr-body.txt; thenecho "Plan deletes or replaces:"; cat deletes.txt; exit 1fipr-body.txtis the pull request description, written to a file by an earlier step. The author opts in with anallow-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 indeletes.txtwith theallow-delete:line before approving. For richer rules (tags, instance sizes, regions) run a policy engine such as Conftest over the sameplan.json. -
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.
-
The workspace owner approves, CI applies the saved plan. CI runs
terraform apply tfplanfrom 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: 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, and wiring the jobs into a pipeline is on CI/CD pipelines with AI.
Detect drift on a schedule and triage it with the agent
Section titled “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.
# Exit code 2 means the real infrastructure differs from stateterraform plan -refresh-only -detailed-exitcode -out drift.tfplanterraform show -json drift.tfplan > drift.jsonID=$(aws cloudformation detect-stack-drift --stack-name db-cluster-prod \ --query StackDriftDetectionId --output text)# Poll until DetectionStatus is DETECTION_COMPLETEaws 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# Shows what a refresh would change in state, without writing itpulumi refresh --preview-only --stack prodBefore 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?
Section titled “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.
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.