Safe Terraform auto-apply with conftest
Safe Terraform<br>auto-apply with conftest
Published Jun 6, 2026 by Ricard Bejarano
You know the ritual: a change is made, Terraform plans, someone reviews it,<br>approves it, and it gets applied. At low enough velocity, this works. The<br>reviewer catches the odd mistakes, and everyone sleeps well.
Past a certain point, the reviewer becomes the bottleneck . Plans pile<br>up, engineers either rush through them or let them sit, and you start losing<br>either velocity or review quality. Often both.
Our immediate next thought is to delegate review to AI .<br>And while you can complement your plan review with AI—the most interesting<br>solution I’ve found in this space is Overmind↗—you<br>cannot fully delegate plan review to it, not for production infrastructure:
it’s non-deterministic : the same plan may pass today and fail tomorrow;
it often violates audit/compliance requirements that mandate human<br>sign-off with clear accountability; and critically
it removes responsibility from the feedback loop , no one owns the<br>decision, which is exactly what you don’t want when something breaks.
There’s a third option: evaluating Terraform plans programmatically and<br>deterministically using policy-as-code. That’s what we do, with<br>conftest↗.
conftest
conftest↗ is a policy-as-code tool built on<br>Open Policy Agent↗. You write policies in<br>Rego↗, feed it<br>JSON data, and it tells you whether your data satisfies your policy.
The key insight is that Terraform can export its plan as JSON :
terraform plan -out=plan.tfplan<br>terraform show -json plan.tfplan > plan.json<br>That JSON file contains every resource change Terraform intends to make:<br>what’s being created, updated, deleted, and the before/after values of each<br>attribute. It’s the same information a human reviewer would look at, in a<br>structured format a policy engine—like conftest—can evaluate:
conftest test plan.json<br>If the plan satisfies your policy, it passes. If it doesn’t, it fails with an<br>explicit reason. The decision is auditable, testable, and reproducible.
An example policy
Here’s a Rego policy that only allows plans where every change is a no-op, a<br>resource create, or a data source read. Any update or delete fails the policy:
package main
import rego.v1
safe_actions := {"no-op", "create", "read"}
deny contains msg if {<br>some resource_change in input.resource_changes<br>some action in resource_change.change.actions<br>not action in safe_actions<br>msg := sprintf(<br>"resource %q has action %q, which is not in the safe set %v",<br>[resource_change.address, action, safe_actions],<br>This policy iterates over every resource_changes entry in the JSON-formatted<br>Terraform plan. For each one, it checks whether all of its actions are in the<br>safe_actions set. If any action falls outside that set (an update or a<br>delete), the policy emits a denial with the offending resource and action.
That’s it. If this policy passes, the plan only creates new resources,<br>reads data sources, or does nothing, so it’s safe to auto-apply. If it<br>fails, the pipeline stops and a human reviews.
Note: depending on what Terraform providers you use,<br>new resource creation may not be completely harmless. Point here is that you<br>create your own policy to suit your organization’s definition of what a “safe to<br>auto-apply” plan means, as we will see below.
Wiring it into your pipeline
The CI/CD integration is straightforward. After Terraform plans, export the plan<br>to JSON, run conftest, and branch on the result:
terraform plan -out=plan.tfplan<br>terraform show -json plan.tfplan > plan.json
if conftest test plan.json; then<br>terraform apply plan.tfplan<br>else<br># gate on human approval<br>fi<br>What makes this work well is that the decision boundary is explicit . You’re<br>not asking someone (or something) to judge whether a plan “looks safe”. You’re<br>checking whether it satisfies a set of rules you defined, tested, and versioned<br>alongside your infrastructure code.
Extending the policy
The example above is deliberately minimal: it only allows creates, data source<br>reads, and no-ops. In practice, you’ll want a richer policy, and the JSON<br>Terraform plan gives you plenty to work with:
Resource types. Not all resources carry the same risk. You might auto-apply<br>changes to CloudWatch alarms, but always gate on RDS instances or IAM policies.<br>The type field on each resource_changes entry gives you this:
safe_resource_types := {"aws_cloudwatch_metric_alarm"}
deny contains msg if {<br>some resource_change in input.resource_changes<br>not resource_change.type in safe_resource_types<br>some action in resource_change.change.actions<br>action not in {"no-op", "read"}<br>msg := sprintf("resource %q has type %q, which is not in the auto-apply safe set", [resource_change.address, resource_change.type])<br>Resource fields. Sometimes the resource type isn’t enough—you want to<br>auto-apply changes that only touch certain...