Article

A destroy gate for Terraform on GitHub's free plan

If your pipeline can apply Terraform, it can delete things, and this is how to make every deletion and every replacement need its own explicit approval using only what a free GitHub plan provides.

6 min read

From the case study How we run our own infrastructure as code.

The problem

A Terraform plan is a list of intentions, and some of them cannot be undone. Deleting a DNS record takes a name offline the moment it applies; neither Route53 nor Cloudflare keeps an undo. Replacing a virtual machine is a rebuild, with whatever was on its disk. A pull request that renames one argument can turn an in-place update into a replace, and the line that says so is easy to miss in a long plan.

The usual answer on GitHub is an Environment with required reviewers: the apply job pauses until a named person approves. That is not available everywhere. On a private repository on a free plan, required reviewers are unavailable, and branch protection and rulesets need a paid plan. If you are the only collaborator, a required review deadlocks anyway, because you cannot approve your own pull request.

So the gate has to live in the workflow. It cannot stop a determined person with push rights to main; nothing in a workflow can. What it can do is make an accidental destroy impossible to apply without a deliberate, specific click. For a small team, that is the failure that actually happens.

The pattern

Four parts, each simple on its own:

  1. Merging never applies. Pull requests plan with read-only credentials. A push to main plans again and stores the plan file as an artifact. Applying is a manual workflow_dispatch.
  2. The apply uses the reviewed plan. The dispatch downloads the plan file built on main and applies that file, never a fresh plan. If state moved, Terraform refuses the stale plan.
  3. Destroys are counted from that same file's JSON, and a replace counts as a destroy.
  4. Each stack has its own approval checkbox. A plan that destroys or replaces anything in a stack fails that stack unless its box is ticked.

Parts 1 and 2 give you "review, then approve". Parts 3 and 4 give you "approve this specific kind of danger, for this specific scope".

The inputs: one checkbox per scope

The dispatch form carries one apply box and one destroy box per stack:

on:
  workflow_dispatch:
    inputs:
      apply:
        description: "Apply the reviewed plans (this click is the approval)"
        type: boolean
        default: false
      # One checkbox per stack. A checkbox cannot be mistyped.
      destroy_stack_a:
        description: "Allow DESTROYS in stack_a"
        type: boolean
        default: false
      destroy_stack_b:
        description: "Allow DESTROYS in stack_b"
        type: boolean
        default: false
      destroy_stack_c:
        description: "Allow DESTROYS in stack_c"
        type: boolean
        default: false

Our first version used one free-text input: a comma-separated list of stack names allowed to destroy. It was the only input where a typo changed behavior: a stack name typed with a hyphen instead of a slash read as "no permission", and the stack was skipped. A 75-line shell job existed only to validate the list, because the GitHub expression language has no replace() to trim it inline. Booleans removed the whole failure mode, and the validation job with it.

We also left out a single global "allow destroys" checkbox. The pipeline's credentials are the union of every stack's, so permission to delete in one stack must not imply permission in another.

Apply the plan you reviewed

The apply job looks for the newest unexpired plan artifact built on main and downloads it:

- name: Find the reviewed plan
  id: find_plan
  uses: actions/github-script@v7
  env: { ARTIFACT: "tfplan-${{ inputs.id }}" }
  with:
    script: |
      const arts = await github.paginate(github.rest.actions.listArtifactsForRepo,
        { owner: context.repo.owner, repo: context.repo.repo, name: process.env.ARTIFACT, per_page: 100 });
      const usable = arts
        .filter(a => !a.expired && a.workflow_run && a.workflow_run.head_branch === 'main')
        .sort((a, b) => new Date(a.created_at) - new Date(b.created_at));
      if (usable.length === 0) { core.setFailed(`No unexpired ${process.env.ARTIFACT} from main.`); return; }
      core.setOutput('run_id', String(usable[usable.length - 1].workflow_run.id));
# ... then actions/download-artifact@v4 with that run-id, which writes ./tfplan

Filter on the branch of the run that produced the artifact. Without that filter, a plan built on a feature branch could be the newest one with the right name.

Count destroys in the file you will apply

Everything that follows reads the downloaded tfplan. Do not grep the human-readable plan; parse terraform show -json:

terraform show -json tfplan > reviewed.json
count() { jq "[.resource_changes[]? | select(${1})] | length" reviewed.json; }

imp=$(count '.change.importing != null')
add=$(count '.change.actions == ["create"]')
chg=$(count '.change.actions == ["update"]')
rep=$(count '.change.actions == ["delete","create"] or .change.actions == ["create","delete"]')
del=$(count '.change.actions == ["delete"]')
echo "| $STACK | $imp | $add | $chg | $rep | $del |" >> "$GITHUB_STEP_SUMMARY"

Two details matter. A replace appears as ["delete","create"] by default and as ["create","delete"] under create_before_destroy; match both. And an import is not a destroy, but it is a change: a stack sitting on unapplied import blocks is not reconciled, so count it separately.

Our earliest gate tested only for ["delete"]. Replaces walked straight through, and the plan summary printed the same row of zeroes for "all clear" and "about to rebuild a VM".

Which file you count matters as much as how. Until October 13, 2026, our own pipeline counted destroys in a fresh plan that the apply job made to decide whether a stack had anything to do, and then applied the downloaded artifact. Those are usually the same plan, but not always. A reviewed plan that destroys something could pass the gate on a day when the fresh plan happened to be clean. We moved the gate to run after the download, on the same file the apply step uses, and it has run that way on every apply since.

The gate

The gate step runs per stack, after the download and the counts, immediately before the apply:

# Runs only on a dispatch with apply=true. Reads reviewed.json from the step above.
if { [ "$del" != "0" ] || [ "$rep" != "0" ]; } && [ "$CONFIRMED" != "true" ]; then
  {
    echo "## $STACK skipped: the reviewed plan destroys or replaces resources"
    echo ""
    echo "$del to destroy, $rep to replace. Re-run with 'apply' ticked and also tick:"
    echo "    Allow DESTROYS in $STACK"
    echo "A replace counts here: a replaced VM is a rebuilt VM, and a deleted DNS"
    echo "record is offline immediately, with no undo."
  } >> "$GITHUB_STEP_SUMMARY"
  echo "::error::$STACK reviewed plan destroys/replaces. Tick 'Allow DESTROYS in $STACK' to apply."
  exit 1
fi
if [ "$del" != "0" ] || [ "$rep" != "0" ]; then
  echo "::warning::$STACK: destroy explicitly confirmed, proceeding."
fi
terraform apply -input=false -lock-timeout=180s tfplan   # the file the gate just read

CONFIRMED is the stack's checkbox, passed into the reusable workflow. Compare it against the literal string "true". Boolean inputs arrive in shell as the strings "true" and "false", and as an empty string on push and pull_request, where no input exists. Truthiness tests will surprise you.

Write the failure into the job summary as well as the log. The person reading it is about to decide whether to tick a box that allows deletion, so give them the counts and the exact name of the box.

Test it against a real deletion

Before relying on our gate we tested it against a plan that really deleted things: 23 unused cloud resources we had adopted into Terraform in order to remove them. First dispatch: apply ticked, no destroy box. The gate failed that stack with zero destructions, while the other stacks, which had nothing to delete, applied normally. Second dispatch, with that stack's box ticked: 23 destroyed. Then we checked in the cloud console that everything meant to survive had survived.

If you have nothing you want to delete, create a throwaway resource for the purpose, such as a single DNS TXT record in a test zone, and delete it through the gate twice: once without the box and once with it.

Traps

  • Replaces hide. A gate on ["delete"] alone lets every replace through. Count both orders.
  • Count from the JSON plan, not the human-readable summary. Plan: 0 to add, 1 to change, 0 to destroy hides nothing today and may hide something after a provider upgrade. Parse the JSON.
  • Gating one plan and applying another. If the job makes its own plan for any reason, make sure the gate reads the file that terraform apply will receive.
  • A typo in a typed scope reads as "no". If the approval is free text, a misspelled stack name means "no permission", and "no permission" means "skip", which looks like success.
  • Empty strings. Inputs are empty outside workflow_dispatch. Compare against "true".
  • Inputs are capped. workflow_dispatch limits how many inputs a workflow can have (10 when we built this; check the current limit). One apply box plus one per stack runs out sooner than you expect, and adding a stack becomes a two-place edit: a job and an input.
  • Every loop must learn about every new stack. Our drift survey, which fails a plan-only dispatch if any stack would change, kept looping over three stacks after we added a fourth, and we fixed it on October 13, 2026. Generate the list from one place, or search for the old list whenever you add a stack.
  • It does not stop an insider. Anyone who can push to main can edit the gate. On a free plan with one or two operators, its job is to stop mistakes, and it should say so in its own header.

We used this in How we run our own infrastructure as code.

Related articles

Running into something like this?

Thirty minutes, no commitment. Bring the problem; we will tell you what we would check first and whether we are the right people for it.

All articles