USE CASE

Nobody destroys
an environment alone.

Infrastructure-as-code makes deleting production as easy as deleting staging — same command, one different variable. Put a human threshold between the plan and the apply.

One flag away from an outage

The ergonomics that make Terraform, Pulumi, and CloudFormation good are the same ones that make them hazardous. The command that tears down a review environment is character-for-character the command that tears down production, differing only in which workspace or variable file is selected. There is no friction distinguishing the routine case from the catastrophic one.

The published post-mortems are consistent about this. The engineer knew what the command did. They were competent, and they were pointed at the wrong target. Better training does not address that; a second person looking at the plan does.

Gate the apply, not the plan

Keep planning free. Anyone should be able to run a plan, see the diff, and share it. The gate belongs on the apply step, and only when the plan contains destructive changes.

In CI, the job runs the plan, counts the deletions, and if there are any against a protected workspace it raises a TeamSigner request with the summary as the reason. The job then waits. Approvers see what is about to be destroyed on their phones and sign or ignore it. On threshold, your endpoint releases the apply.

# .github/workflows/infra.yml — gate applies that delete things
- name: Plan
  run: terraform plan -out=tf.plan && terraform show -json tf.plan > plan.json

- name: Count destructive changes
  id: destroy
  run: |
    n=$(jq '[.resource_changes[]
             | select(.change.actions | index("delete"))] | length' plan.json)
    echo "count=$n" >> "$GITHUB_OUTPUT"

- name: Request team approval
  if: steps.destroy.outputs.count != '0'
  run: |
    ./scripts/teamsigner-raise.sh \
      --action "APPLY DESTRUCTIVE INFRA CHANGE" \
      --message "$GITHUB_REF: ${{ steps.destroy.outputs.count }} resources will be destroyed"
    ./scripts/teamsigner-wait.sh   # blocks until approved, fails on expiry

- name: Apply
  run: terraform apply tf.plan

The wait script polls your own service, which is what actually receives the approval webhook — TeamSigner delivers to you, and your CI asks you.

Scope it so it does not become noise

A gate that fires on every apply gets approved reflexively within a fortnight, at which point it is theatre. Keep the trigger narrow and the signal stays meaningful.

  • Only protected workspaces — production and anything holding customer data. Review environments should never prompt.
  • Only when the plan actually deletes or force-replaces resources. Additions and in-place updates go straight through.
  • Put the resource count and the workspace in the reason field, so an approver can judge without opening a laptop.
  • Use the initiator allowlist so the request can only originate from your CI identity, not from anyone's terminal.

Common questions

Does this work with Pulumi, CDK, or CloudFormation?

Yes. TeamSigner is not tied to any tool. Anything that can make an HTTP call to raise a request and can wait for your endpoint to confirm approval will work the same way.

What happens if nobody approves?

Requests expire. The pipeline job fails on expiry the same way it would fail on any other timeout, and the apply never runs.

Can the person who raised the request also approve it?

That is up to how you set the threshold and who is on the team. If you want strict dual control, set the threshold to two and treat the initiator's own approval as one of them only if your policy permits self-approval.

Put a threshold on it.

Define the action, pick how many approvals it needs, point it at your webhook. Free while we're getting started.

Log in & get started

Integrating? The full webhook contract is in the docs.

Related

Production deploys
Use case
Production database
Use case
Separation of duties
Guide