ActionBoxBlog

DevOpsPlatformSREBackend

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.

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

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

Try it

  1. Install the CLI on your runner
  2. Create a Source → ACTIONBOX_TOKEN
  3. Copy the detect-destroy.sh + gate job into your Terraform workflow

Create a free Source · Terraform + CI reference · Deploy approval gates in GitHub Actions

Try this workflow in minutes

Create a free Source, then run the exact commands from this post against the live API — no approval infrastructure to build.

S
Suson Sapkota

Founder, Actionbox