Skip to content

Terraform plan approval: protect destroy and replace operations

A copy-paste Terraform plan approval gate that detects destroy and replace operations in CI, blocks apply, and records the human decision.

Network switch and blue cables representing infrastructure

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:

  1. plan runs and saves its output to a file
  2. A gate step greps for will be destroyed / must be replaced
  3. If destructive, it creates an approval with the plan summary as context
  4. apply runs only on approval; fails closed on reject or timeout
Architecture Flow
Clean plan Destroy or Replace Approve Reject / Timeout terraform plan Destructive Change? Auto-apply changes Actionbox Gate terraform apply Cancel & Alert On-call

Step 1: detect the destroy in CI

The plan output is the contract — parse it mechanically before any human gets involved:

bash
#!/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"
fi

Step 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:

yaml
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: python -m pip install actionbox

      - 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 tfplan

When 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:

yaml
- name: Fail on reject or timeout
  if: steps.gate.outputs.decision != 'approve'
  run: |
    echo "Destructive plan NOT approved (decision: ${{ steps.gate.outputs.decision }})"
    exit 1

The 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 terraform for tofu; the plan format is identical

Full example: OpenTofu with a copyable apply script

bash
#!/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 tofuplan

Sources & Research

Try it

  1. Install the CLI on your runner
  2. Create a Source in the dashboard → ACTIONBOX_TOKEN
  3. Add the plan-check step to your pipeline

Create a free Source · Full API reference · Deploy approval gates in GitHub Actions · Argo CD sync approval gate

About the author

Suson Sapkota

Suson founded ActionBox and works in software and data engineering. He writes about approval workflows, background jobs, and how to verify what happened after a human decision.

Turn the next risky operation into a reviewable decision.

Create a free Source, run the example from this guide, and keep the decision and execution outcome connected.