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.
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:
- Merging never applies. Pull requests plan with read-only credentials. A push to
mainplans again and stores the plan file as an artifact. Applying is a manualworkflow_dispatch. - The apply uses the reviewed plan. The dispatch downloads the plan file built on
mainand applies that file, never a fresh plan. If state moved, Terraform refuses the stale plan. - Destroys are counted from that same file's JSON, and a replace counts as a destroy.
- 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 destroyhides 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 applywill 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_dispatchlimits how many inputs a workflow can have (10 when we built this; check the current limit). Oneapplybox 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
maincan 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
Crash forensics for a cloud VM in one Ansible run
A read-only Ansible playbook, written once and run the moment a failed VM comes back, collects the evidence for the usual causes before it rotates away, and on a client's production server it pointed to CPU-credit throttling.
Verified Postgres TLS when no host chains to a public root
If your app connects to Postgres with
rejectUnauthorized: false, the connection is encrypted but the server's identity was never checked. Here is how to turn verification on when the certificate is signed by a private CA, without taking production down.Silent zeros: four times a pipeline said success and was not
A batch job that crashes is easy to deal with: something goes red and someone looks. The harder case is a job that runs, exits 0, posts a green tick, and produced nothing, or produced yesterday's data again, or checked nothing at all. We call these silent zeros.
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.