Automating Preview Environment Teardown to Control Costs

You Googled this because your preview environment bill keeps climbing from environments that never got cleaned up — PRs closed without teardown, branches deleted directly, abandoned drafts left running. This page reclaims that spend with two independent mechanisms: immediate teardown on PR close and a scheduled idle-TTL sweep that does not depend on webhooks, both building on the ephemeral preview routing setup.

When to use this pattern

  • You run per-PR preview environments and have seen orphaned namespaces accumulate.
  • You want cost attribution (chargeback) per team and a hard cap on how long any preview survives.
  • You need teardown that survives missed webhooks and direct branch deletions.

Prerequisites

Complete working example

# .github/workflows/preview-teardown.yml — two triggers, one safety net
name: Preview Teardown
on:
  pull_request:
    types: [closed]          # immediate teardown
  schedule:
    - cron: "0 */2 * * *"    # idle sweep every 2 hours (webhook-independent)

jobs:
  # 1. Immediate teardown when a PR closes (merged or not).
  close:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - name: Configure kubectl
        run: echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config" && mkdir -p "$HOME/.kube"
      - name: Delete the PR namespace
        run: |
          kubectl delete namespace "preview-${{ github.event.pull_request.number }}" \
            --ignore-not-found --wait=false
          echo "Torn down preview-${{ github.event.pull_request.number }}"

  # 2. Scheduled safety net: destroy environments idle past the TTL.
  sweep:
    if: github.event_name == 'schedule'
    runs-on: ubuntu-latest
    steps:
      - name: Configure kubectl
        run: echo "${{ secrets.KUBE_CONFIG }}" | base64 -d > "$HOME/.kube/config" && mkdir -p "$HOME/.kube"
      - name: Destroy idle preview namespaces
        run: |
          IDLE_SECONDS=$((4 * 3600))     # 4h idle TTL
          HARD_TTL_SECONDS=$((24 * 3600)) # 24h absolute cap
          NOW=$(date +%s)
          kubectl get ns -l preview=true -o json | jq -r --argjson now "$NOW" \
            --argjson idle "$IDLE_SECONDS" --argjson hard "$HARD_TTL_SECONDS" '
            .items[]
            | .metadata as $m
            | ($m.annotations["preview/last-traffic"] // $m.annotations["preview/created"] | tonumber) as $last
            | ($m.annotations["preview/created"] | tonumber) as $created
            | select( ($now - $last) > $idle or ($now - $created) > $hard )
            | $m.name' \
          | while read -r ns; do
              echo "Sweeping idle/expired namespace: $ns"
              kubectl delete namespace "$ns" --ignore-not-found --wait=false
            done

At provisioning time, every namespace must be tagged so the sweep and chargeback can find it:

# Run in the provisioning job, once per PR
kubectl label namespace "preview-${PR}" preview=true team="${TEAM}" pr="${PR}"
NOW=$(date +%s)
kubectl annotate namespace "preview-${PR}" \
  "preview/created=${NOW}" "preview/last-traffic=${NOW}" --overwrite

Step-by-step walkthrough

Immediate teardown (close job). The pull_request: closed event fires on both merge and manual close. Deleting the namespace cascades to the deployment, service, ingress, and secret — everything the preview created. --wait=false returns immediately so the job does not block on finalizers.

Tagging at creation. The preview=true label is what the sweep selects on; team powers chargeback; preview/created and preview/last-traffic timestamps drive the TTL math. Without these, the sweep cannot distinguish a preview from any other namespace.

Idle sweep (sweep job). Running on a two-hour cron independent of webhooks, it lists labelled namespaces and deletes any idle beyond the 4-hour TTL or past the 24-hour hard cap. The jq expression falls back to created when no last-traffic annotation exists, so environments are never immortal. This is the safety net that catches the environments PR-close teardown misses.

Chargeback. Because every namespace carries a team label, a separate reporting query can sum compute per team — feeding the same cost discipline as tracking CI/CD compute costs for platform teams.

Verification

# After closing a PR, the namespace is gone within a minute
kubectl get ns preview-123    # → Error: namespaces "preview-123" not found

# Simulate an orphan: create a namespace with an old last-traffic and run the sweep logic
kubectl create ns preview-999
kubectl label ns preview-999 preview=true
kubectl annotate ns preview-999 "preview/created=1" "preview/last-traffic=1"
# The next scheduled sweep (or a manual run) deletes preview-999 as expired.

Expected: closed PRs are reclaimed immediately, and any namespace whose timestamps exceed the TTL is swept on the next schedule.

Common pitfalls

  • Relying on PR-close alone. Webhooks miss; branches get force-deleted. Without the scheduled sweep, orphans accumulate silently until the bill spikes. Always run both.
  • No hard TTL. An environment that keeps receiving a trickle of automated traffic (uptime pings, bots) can dodge the idle check forever. The HARD_TTL_SECONDS cap guarantees an upper bound regardless of activity.
  • Untagged namespaces. If provisioning forgets the preview=true label, the sweep cannot see the environment and it lives forever. Make tagging part of the provisioning step, not an afterthought.

← Back to Ephemeral Preview URL Management and Routing