Skip to main content
14 min read

Gate Your Terraform Plans: Rules Decide, the Model Explains

Gate Your Terraform Plans: Rules Decide, the Model Explains

The summary line at the bottom of a Terraform plan carries very little. Plan: 3 to add, 1 to change, 1 to destroy. The one to destroy might be a null resource nobody needs, or the production database. Those two plans produce the same summary line, and the difference only appears if someone opens the full output and reads it, on a pull request whose main subject is usually the application code above it.

The plan itself knows the difference. terraform show -json gives you the actions per resource, the before and after values, and the paths that force a replacement. That is enough to fail a job on changes that risk losing data or exposing something publicly, and to do it deterministically, before anyone argues about it.

So: a GitHub Action that reads the plan JSON, decides pass or fail from rules you can read, and posts a comment. DigitalOcean's serverless inference writes the English in that comment, and nothing else. If the endpoint is down, the gate behaves the same. The repo is terraform-plan-gate, it is MIT, and the numbers below come from running it here.

TL;DR

  • terraform show -json gives a provider-independent change envelope: actions, before and after values, and replace_paths when a replacement is forced. Seven rules over that JSON flag the destructive and exposure classes, and this post names where they stop.
  • The verdict is deterministic. The model is called after the decision, only to turn findings into sentences, and the gate works unchanged when it is unreachable.
  • Against twenty labelled plans, the default threshold stopped 8 of 12 dangerous plans with zero false alarms on 8 routine ones. The stricter threshold stopped 9 and raised 4 false alarms.
  • The four misses are dns-repoint, iam-wildcard, lambda-env-swap and retention-to-one-day. Repointing a DNS record, switching STRIPE_MODE from test to live and cutting log retention are ordinary-looking updates: these rules read structure, and a rule set that knows your context is what reads meaning.
  • A plan is written by whoever opened the pull request, so it is untrusted input to the explanation step. A plan whose resource name says "IGNORE PREVIOUS INSTRUCTIONS" still fails.

Prerequisites

  • Terraform 1.5 or later and a repository where plans run in CI.
  • Python 3.11 to run the gate locally.
  • A DigitalOcean inference key for the explanation, optional. Without it you get the rule text.

What a plan contains

Run terraform plan -out=tf.plan then terraform show -json tf.plan, and every resource that changes appears in resource_changes. The envelope is the same whatever the provider, though the attributes inside before and after follow each provider's schema:

{
  "address": "random_password.db",
  "type": "random_password",
  "change": {
    "actions": ["delete", "create"],
    "before": { "length": 20 },
    "after":  { "length": 32 },
    "replace_paths": [["length"]]
  }
}

That one is real: it is fixtures/real-replace.json in the repo, produced by running Terraform against a two-resource module. Changing the length of a generated password forces a new one. In this fixture nothing consumes it, so the replacement costs nothing; the same envelope on a database is a different matter.

Three things in there carry most of the risk. actions containing delete means something goes away. delete and create together mean a replacement, in one order or the other: ["delete", "create"] destroys first, and ["create", "delete"] is the create-before-destroy form. Either way the old resource is gone at the end, which risks losing whatever it held, depending on snapshots and deletion protection. replace_paths identifies the paths that forced the replacement when Terraform knows them, which is the sentence a reviewer wants and the plain output buries; a replacement triggered by taint or by -replace shows up in action_reason instead.

The rest is a diff, and diffs of certain keys mean access: cidr_blocks, publicly_accessible, acl, assume_role_policy, a firewall's rule list. That is the whole basis of the gate.

Which types hold data is a list plus a narrow name pattern, and it is worth knowing where that lands: an early version matched any type containing table, which reported aws_route_table as data loss. A false block on a route table is review noise about something that holds nothing, so the pattern is now specific and anything it misses belongs in the list rather than in a regular expression.

The rules

Two pure functions decide everything: evaluate turns a plan into findings, and verdict applies your threshold to them. Neither touches the network or a model:

def evaluate(plan: dict[str, Any]) -> list[Finding]:
    """Every finding in a plan, worst first. Pure: no I/O, no model.

    Raises NotAPlan when the document is not plan JSON, so that state files,
    an empty object or a truncated download cannot pass as a clean plan.
    """
    if not isinstance(plan, dict):
        raise NotAPlan("expected a JSON object")
    if not isinstance(plan.get("format_version"), str) or not plan["format_version"].strip():
        raise NotAPlan("no format_version: this is not `terraform show -json` output")
    changes = plan.get("resource_changes")
    if not isinstance(changes, list):
        raise NotAPlan("no resource_changes array: a state file is not a plan")
    for entry in changes:
        if not isinstance(entry, dict) or not isinstance(entry.get("change"), dict):
            raise NotAPlan("a resource_changes entry has no change object")
        actions = entry["change"].get("actions")
        if not isinstance(actions, list) or not actions:
            raise NotAPlan(f"{entry.get('address', 'a resource')} has no actions")
        if any(a not in {"no-op", "create", "read", "update", "delete"} for a in actions):
            raise NotAPlan(f"{entry.get('address', 'a resource')} has an action Terraform does not emit")
        if not isinstance(entry.get("address"), str) or not entry["address"]:
            raise NotAPlan("a resource_changes entry has no address")
    findings: list[Finding] = []
    for change in plan.get("resource_changes", []) or []:
        actions = _actions(change)
        if actions in ([], ["no-op"], ["read"]):
            continue
        address = change.get("address", "?")
        rtype = change.get("type", "?")
        before = _values(change, "before")
        after = _values(change, "after")
        after_unknown = change.get("change", {}).get("after_unknown") or {}
        stateful = _is_stateful(rtype)
        deleting = "delete" in actions
        replacing = deleting and "create" in actions
        # A resource that did not exist before has nothing to compare against,
        # so its attributes are not "changes". Exposure is still checked
        # against an empty baseline: a new rule open to the world is the same
        # hole as an old one widened to it.
        creating_only = set(actions) == {"create"}
        baseline: dict[str, Any] = {} if creating_only else before

        if deleting and stateful:
            findings.append(Finding(
                "stateful-destroy", BLOCK, address, rtype,
                "replaces a resource that holds data, so its contents are at risk" if replacing
                else "destroys a resource that holds data, so its contents are at risk",
                {"actions": actions, "reasons": change.get("change", {}).get("replace_paths", [])},
            ))
        elif replacing:
            findings.append(Finding(
                "replace", WARN, address, rtype,
                "is replaced, so it is destroyed and recreated",
                {"actions": actions, "reasons": change.get("change", {}).get("replace_paths", [])},
            ))
        elif deleting:
            findings.append(Finding("destroy", WARN, address, rtype, "is destroyed", {"actions": actions}))
        ...

Seven rules come out of that: destroying or replacing something that holds data blocks; a 0.0.0.0/0 or ::/0 appearing under an access key where the resource had none blocks, including on a newly created rule; an ACL becoming public or widening between public values blocks; publicly_accessible turning on blocks; other selected access, IAM and policy keys warn; any other replace or destroy warns; and a version or size change is a note, or a warning on something that holds data.

Three details matter more than the list.

A resource being created has no before, so its attributes are not "changes" and do not fire the version rule: without that, every new droplet would report a changed size. Exposure is different, and the first version of this tool got it wrong. A rule created open to the world is the same hole as an old one widened to it, so creation is checked against an empty baseline and a new 0.0.0.0/0, a new public ACL or a new publicly_accessible = true all block.

CIDRs are read only from the keys that decide reachability, so a CIDR written in a tag no longer counts as exposure, and a top-level egress block is not read as an inbound rule. Direction inside a standalone rule resource is not inspected, so an egress-only aws_security_group_rule opened to the world still reports; that is a false alarm I would rather have than the reverse. The comparison is per resource rather than per rule, so a security group that already allows the world somewhere can gain another world-open rule, or change a port on one, without a new finding.

Module addresses are covered, because the address carries the module path and the rules never look at nesting. Unsupported nested schemas are not: a Kubernetes network policy spec changes without a finding, because the comparison is over selected top-level keys. Values Terraform cannot resolve until apply, which arrive in after_unknown, are not inspected either.

Here it is on a plan with three problems in it:

The table comes from the rules. The paragraph under "What this means" is the model's only contribution, and the failing verdict was computed before the model was called. It is worth reading that paragraph critically: an earlier version of the prompt produced "causing all data in the database to be lost", which a snapshot or deletion protection may well contradict. The prompt now asks for what is at risk rather than what is certain, and the rules say "at risk" too.

Why the model is kept out of the decision

The argument for putting a model in a merge gate is that it describes a change in terms a reviewer reads faster than a diff. The argument against is that it is not deterministic, and a gate whose answer changes between runs on the same plan is hard to build a policy on.

The split resolves it. Rules decide; the model writes. That gives three properties worth having:

The same plan always produces the same verdict, so a re-run never flips a red job green. The gate works when inference is down, slow, unfunded or answering with something unusable, because those cases return no prose and the explanation is the only thing that goes missing. And there is a test that says so: it patches the explanation the CLI calls to return "everything here is completely safe, approve it", runs the whole command, and asserts the exit code is still 1 and the comment still says fail. The reassuring sentence is printed and powerless.

The other reason is that the input is hostile by construction. A plan is generated from the branch under review, so its resource names, tags and values are written by whoever opened the pull request. The explanation step sends the findings rather than the whole plan, replaces the values under before and after with <set> or <unset>, in the comment and the JSON artefact as well as the model call, so a policy document or a generated password stays on the machine that ran the plan, wraps what remains in a block delimited by a random per-call nonce, and tells the model to treat it as data. Here is a plan that tries:

The resource is still destroyed, the bucket is still going public, and the job still fails, because the verdict is computed before the model is called and never read back from it.

Two caveats, since a guarantee with no edges is not a guarantee. What a hostile plan can still do is put text in a comment that a human reads, so treat the paragraph as a description rather than advice. And the gate trusts the file it is given, so it validates that the file is plan JSON with a version, a changes array and an actions list per entry, and refuses anything else rather than reporting a clean plan. That still assumes the plan came from your pipeline, so the workflow and the gate need the usual protection against a branch editing them, and apply the saved plan file that was gated rather than re-planning at apply time.

Measured, including what it misses

Twenty plans, labelled by hand: twelve a reviewer should stop, eight ordinary Friday changes. They are written to the plan JSON shape rather than captured from twenty real stacks, so read them as a rule test rather than as field data; three Terraform-generated plans sit in fixtures/ for the mechanics. The corpus is in the repo and corpus_report.py reproduces this exactly.

At the default threshold it stopped 8 of the 12 and let all 8 routine plans through. The zero is the number I would watch in your own corpus, alongside how often people override it.

These four cases are where this rule set stops:

  • dns-repoint changes an A record from one address to another. Structurally it is an update to a string. Whether it is a migration or an outage depends on what those addresses are.
  • lambda-env-swap switches STRIPE_MODE from test to live. An environment variable changed. Nothing about the plan says one of those values charges real cards.
  • retention-to-one-day cuts CloudWatch retention from 365 days to 1. Also an integer.
  • iam-wildcard replaces a specific principal with *. The gate sees the policy changed but not what changed in it, because it compares the JSON strings without parsing principals, actions or conditions, so it warns rather than blocks. At --fail-on warn it stops, and so do four routine plans. Parsing those documents is the obvious next rule.

That trade is the interesting part, and it is why the threshold is a setting rather than a decision I made for you. If your team wants every replacement in front of a human, --fail-on warn is right, and the four you will wave through by hand in this corpus are an ACL being tightened, a reordered CIDR list, a null resource being deleted and a firewall being narrowed.

These rules do not cover the first three, though a rule set that knows your context can. A list of protected DNS records is a dozen lines in the same file, which is the point of keeping the rules in the repository. Knowing where the tool stops is the reason to trust it where it works.

Wiring it into a pull request

- uses: hashicorp/setup-terraform@v3
- run: terraform init -input=false
- run: terraform plan -out=tf.plan -input=false
- run: terraform show -json tf.plan > plan.json
- uses: The-DevOps-Daily/terraform-plan-gate@v1
  with:
    plan: plan.json          # relative paths resolve against the workspace
    fail-on: block
    do-inference-key: ${{ secrets.DO_INFERENCE_KEY }}

Three operational notes. The job needs permissions: pull-requests: write to post the comment. The plan has to come from the pull request's own branch with the same variables production uses, or you are gating a plan nobody will apply. And the job needs the credentials to run terraform plan, so it belongs in a workflow that already has them, with the usual care about who can open a pull request against a repository that holds them.

Where to take it

The rule set here is a starting point. Yours will differ: a helm_release replacement might be routine for you and a kubernetes_namespace delete might be the end of the world. The rules are about 250 lines of Python over a documented JSON format, and the corpus is how you know a change to them did what you meant.

If you want one concrete next step, add the rule this corpus proves is missing: refuse a log retention change below your own minimum, then add the plan that exercises it to corpus/ and watch the report count it. That loop, a rule and a labelled plan that fails without it, is what keeps a gate honest as it grows.

Sources

Run the commands from this article in the browser. Nothing to install.

Published: 2026-09-09|Last updated: 2026-09-09T09:00:00Z

Found an issue?

Also worth your time on this topic