A typo in a variable can make terraform plan report "2 resources to be destroyed". If apply runs automatically, that typo is production gone in 15 minutes — and it happens exactly when nobody is reading the plan output. The fix isn't "review harder", it's a destroy gate: CI detects destructive changes and refuses to apply until a human signs off on the exact plan.
What you'll build
A Terraform pipeline where:
planruns and saves its output to a file- A gate step greps for
will be destroyed/must be replaced - If destructive, it creates an approval with the plan summary as context
applyruns only on approval; fails closed on reject or timeout
Step 1: detect the destroy in CI
The plan output is the contract — parse it mechanically before any human gets involved:
#!/usr/bin/env bash
set -euo pipefail
PLAN_FILE="plan.txt"
if ! terraform plan -no-color -out=tfplan > "$PLAN_FILE" 2>&1; then
cat "$PLAN_FILE"
exit 1
fi
DESTROY=$(grep -cE "^\s+# .* will be destroyed$" "$PLAN_FILE" || true)
REPLACE=$(grep -cE "^\s+# .* must be replaced$" "$PLAN_FILE" || true)
echo "destroy=$DESTROY" >> "$GITHUB_OUTPUT"
echo "replace=$REPLACE" >> "$GITHUB_OUTPUT"
if [ "$DESTROY" -gt 0 ] || [ "$REPLACE" -gt 0 ]; then
echo "DESTRUCTIVE_CHANGES=yes" >> "$GITHUB_ENV"
else
echo "DESTRUCTIVE_CHANGES=no" >> "$GITHUB_ENV"
fiStep 2: gate the apply on the detection
The apply job only runs when the gate resolved to approve — and the gate only exists when the plan is destructive:
name: terraform-apply
on:
push:
branches: [main]
permissions:
contents: read
jobs:
plan:
outputs:
apply_decision: ${{ steps.gate.outputs.decision || 'approve' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.11.0
- name: Plan + detect destructive changes
id: detect
run: |
terraform init -input=false
bash ./detect-destroy.sh
- name: Install Actionbox CLI
if: env.DESTRUCTIVE_CHANGES == 'yes'
run: curl -fsSL https://actionbox.cloud/install.sh | sh
- name: Gate destructive changes
if: env.DESTRUCTIVE_CHANGES == 'yes'
id: gate
env:
ACTIONBOX_TOKEN: ${{ secrets.ACTIONBOX_TOKEN }}
run: |
result=$(actionbox ask "Terraform plan contains ${{ steps.detect.outputs.destroy }} destroy + ${{ steps.detect.outputs.replace }} replace — approve apply?" \
--option approve="Approve apply" \
--option reject="Reject plan" \
--context-json "[{\"type\":\"key_value\",\"items\":{\"destroy\":\"${{ steps.detect.outputs.destroy }}\",\"replace\":\"${{ steps.detect.outputs.replace }}\",\"plan\":\"terraform plan -no-color\"}}]" \
--wait --timeout 30m --json)
echo "decision=$(jq -r '.decision // empty' <<< "$result")" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@v4
with:
name: terraform-plan
path: tfplan
apply:
needs: plan
runs-on: ubuntu-latest
if: needs.plan.outputs.apply_decision == 'approve'
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.11.0
- uses: actions/download-artifact@v4
with:
name: terraform-plan
- name: Apply approved plan
run: |
terraform init -input=false
terraform apply -auto-approve tfplanWhen the plan is clean (DESTRUCTIVE_CHANGES=no) the gate is skipped and the pipeline applies without friction — you only get a decision in your inbox when there's something worth deciding about.
Step 3: reject means nothing runs
A rejected or timed-out gate must not silently become a green pipeline. Branch explicitly:
- name: Fail on reject or timeout
if: steps.gate.outputs.decision != 'approve'
run: |
echo "Destructive plan NOT approved (decision: ${{ steps.gate.outputs.decision }})"
exit 1The audit record shows the decision, the reason, and the plan summary the approver actually saw — so "why did this run" is answerable three months later.
Why this beats "everyone must review every plan"
- Zero friction on safe plans — clean applies ship with no sign-off needed
- Forced judgment on destructive ones — the approver sees destroy/replace counts before the button
- Fail-closed by default — no answer, no apply, and the non-decision is on record
- Same gate for OpenTofu — swap
terraformfortofu; the plan format is identical
Full example: OpenTofu with a copyable apply script
#!/usr/bin/env bash
# tofu-apply.sh — requires TOFU_PLAN approved by the gate
set -euo pipefail
tofu init -input=false
tofu plan -no-color -out=tofuplan > plan.txt 2>&1 || true
DESTROY=$(grep -cE "^\s+# .* will be destroyed$" plan.txt || true)
if [ "$DESTROY" -gt 0 ]; then
echo "Refusing to apply: $DESTROY resources will be destroyed"
echo "Ask for approval first, then re-run."
exit 1
fi
tofu apply -auto-approve tofuplanTry it
- Install the CLI on your runner
- Create a Source →
ACTIONBOX_TOKEN - Copy the
detect-destroy.sh+ gate job into your Terraform workflow
Create a free Source · Terraform + CI reference · Deploy approval gates in GitHub Actions