TL;DR
I spent three months letting an AI coding agent write Terraform against real infrastructure. The thing that made it work wasn't a better prompt β it was realizing that terraform plan -json is a machine-readable dry run of your blast radius, which makes it the most honest feedback loop you can hand an agent. Here's the classifier I put in front of it, the near-miss that taught me to distrust the agent's own summary, and 5 lessons.
The Problem
Application code has a cheap, fast, honest feedback loop: run the tests. An agent writes code, runs the suite, sees red, iterates. No human in the loop, and the worst case is a wasted minute.
Infrastructure code doesn't work like that.
main.tf looks like config. It reads like config. Then you apply it and a cache cluster with warm working-set data gets destroyed and recreated, because you changed a field the provider treats as immutable and nobody read the middle of the plan output.
So for a long time, infra/ was the one directory my agent wasn't allowed to touch. Which was annoying, because most of my infra work is genuinely boring: add a queue, attach an IAM policy, bump a provider version, add cost-allocation tags to forty resources. That is exactly the high-volume, low-creativity work I want to delegate.
The real question was never "can the model write HCL?" It can β it's frankly better at provider argument names than I am. The question was:
Can I build a feedback loop for infrastructure that is as honest as a test suite?
Turns out Terraform already ships one. I just wasn't using it as a machine input.
How I Solved It
plan is the test suite
Everyone runs terraform plan and skims the human-readable output. But you can ask for it as structured data:
terraform plan -out=tfplan -input=false -lock=false
terraform show -json tfplan > plan.json
Inside plan.json, every resource shows up under resource_changes[] with an actions array. The whole risk model of Terraform collapses into that one field:
actions |
What it means | How scared to be |
|---|---|---|
["no-op"] |
nothing | π΄ |
["create"] |
new resource | π |
["update"] |
in-place edit | π€ |
["delete", "create"] |
replacement | π± |
["delete"] |
destroy | π± |
That's it. create and update are cheap and reversible. delete and delete,create are where your outage lives. So I wrote a ~30-line classifier that the agent runs itself, and that exits non-zero on anything destructive:
#!/usr/bin/env python3
"""Classify a Terraform plan by blast radius. Exit 2 if a human needs to look."""
import json, sys
DESTRUCTIVE = {"delete", "replace"}
with open("plan.json") as f:
plan = json.load(f)
changes = []
for rc in plan.get("resource_changes", []):
actions = rc["change"]["actions"]
if actions == ["no-op"]:
continue
kind = "replace" if actions == ["delete", "create"] else actions[0]
changes.append({"address": rc["address"], "kind": kind})
destructive = [c for c in changes if c["kind"] in DESTRUCTIVE]
print(json.dumps({
"total": len(changes),
"by_kind": {k: sum(1 for c in changes if c["kind"] == k)
for k in {c["kind"] for c in changes}},
"destructive": destructive,
}, indent=2))
sys.exit(2 if destructive else 0)
Now the agent has a red/green signal for infrastructure. The loop looks like this:
flowchart TD
A[Agent edits .tf files] --> B[terraform fmt + validate]
B -->|fails| A
B -->|passes| C[terraform plan -out]
C --> D[classify.py plan.json]
D -->|exit 0| E[Open PR with plan summary]
D -->|exit 2| F[STOP: name the resource<br/>being replaced and why]
F --> G[Human decides]
The agent iterates freely on the left side of that diagram, and physically cannot proceed past the right side. Same shape as write-code β run-tests, except the "test" is a dry run against real cloud state.
Take away the credential, don't add the instruction
This is the part I'd push back on if I read it in someone else's post, so let me be blunt about it.
I did not write "never run terraform apply" in a rules file and call it a day. Instructions are a suggestion to a system that occasionally decides your intent is more important than your words. Instead:
- The agent runs with a read-only cloud role. It can
plan. It cannotapply, because the credential it holds cannot mutate anything. -
applyruns in CI, under a different role, gated on a human approving the PR. -
planruns with-lock=falseso a crashed agent can't leave the state lock wedged for everyone else.
The agent isn't the security boundary. IAM is. If your safety story depends on the model choosing not to do something, you don't have a safety story β you have a hope.
The near-miss that changed my gate
About six weeks in, I asked for what sounded like a chore: "move the cache cluster onto the new private subnets."
The HCL it produced was correct. Genuinely correct β right subnet group, right security group references, clean fmt, passing validate. And its PR description said:
This change moves the cache cluster to the new private subnet group.
Which is true. It is also missing the only word that mattered. Changing the subnet group on that resource type forces replacement β destroy the cluster, create a new one, come up with an ice-cold cache in front of the primary database during business hours.
The agent wasn't lying to me. It was describing intent when I needed effect. Those two things happen to be identical for application code and wildly different for infrastructure, and I'd never had to notice the distinction before.
The classifier caught it. plan.json said ["delete", "create"], the script exited 2, the loop stopped. What I changed after that wasn't the prompt β it was where I pointed the gate:
Gate on the plan, never on the agent's description of the plan.
Anything the agent writes in prose is a claim. plan.json is evidence. Only one of those should be able to unblock a merge.
Blast radius = directory, not -target
My first instinct was to scope changes with terraform -target=.... Don't. -target skips dependency resolution, so the plan you're reviewing isn't the plan you'd get from a real apply β you've made your evidence less trustworthy to make it smaller.
Scoping by module root works much better, because the boundary is already in your filesystem:
## Terraform rules
- You may edit `infra/modules/**` and `infra/envs/staging/**`.
- You may NOT edit `infra/envs/prod/**`. Describe the diff in the PR body instead.
- Always run `make plan` and paste the classifier JSON into the PR description.
- If the classifier exits 2, stop. Name every resource being replaced and
explain what triggers the replacement.
- Never add `lifecycle { ignore_changes = ... }` to silence a diff you don't understand.
That last rule is scar tissue. There was a perpetual diff on a resource β a tag being set outside of Terraform, so every plan showed the same one-line update forever. The agent's fix was to add ignore_changes and declare the plan clean. Which works, in the sense that a piece of tape over the check-engine light works. It optimized for the signal I'd given it (green plan) instead of the thing I cared about (state matching reality).
Lessons Learned
1. A dry run beats a description, every single time. If your tool can produce a machine-readable preview of its own effects, that preview is your feedback loop β and your gate. Terraform has plan -json. Kubernetes has kubectl diff --server-side. Database migration tools mostly have a --dry-run. Look for the preview before you look for a better prompt.
2. Take away the credential, not the permission to be tempted. A rules file is a suggestion. An IAM policy is a fact. Every "never do X" instruction you write should make you ask whether you could just make X impossible instead.
3. Replacement is the only failure mode worth building a gate around. I spent a while trying to score risk on many axes β resource type, environment, number of changes. Useless. In practice the entire distribution collapses to: does anything get deleted? One axis, near-zero false positives, catches everything that could actually page me.
4. Agents silence diffs; humans fix drift. Any metric you gate on will get satisfied by the shortest available path. If green plan is the goal, ignore_changes is a valid strategy. If you don't want that, say so explicitly β and go read how things went green, not just that they did.
5. Your plan is only as honest as your state. All of this rests on the assumption that state reflects reality. Every console click someone makes at 2am erodes that. The agent didn't cause my drift problem, but it made it expensive enough that I finally fixed it β which was, honestly, worth more than the Terraform it wrote.
What's Next
Three things I'm actively working on:
- Cost as a risk axis. Pipe a cost-estimation diff into the same classifier and treat "+$400/month" as a stop condition alongside "delete". Money is a blast radius too.
-
Policy as code in the loop. Run policy checks against
plan.jsonso the agent gets "this violates the tagging policy" as structured feedback it can iterate on, rather than as a review comment three hours later. -
The same pattern for Kubernetes.
kubectl diff --server-sidegives you a comparable preview. I want one classifier, several backends.
The general shape I keep coming back to: find the dry run, make it structured, gate on it, and revoke the credential for everything downstream of the gate. That's most of it.
Versions, since this stuff rots: Terraform v1.9.x, AWS provider v5.x, Python 3.13, Claude Code (Aug 2026).
Wrap-up
If you're holding your infra repo back from your agent, I'd start here: run terraform show -json, look at resource_changes[].change.actions, and notice that you already have a test suite β you've just been reading it with your eyes instead of parsing it.
If you found this useful, follow me here on Dev.to β I write up this kind of thing as I break it. And I want to hear your version: what's the scariest plan output an agent has ever handed you? Drop it in the comments π
Top comments (0)