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
doneAt 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}" --overwriteStep-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_SECONDScap guarantees an upper bound regardless of activity. - Untagged namespaces. If provisioning forgets the
preview=truelabel, the sweep cannot see the environment and it lives forever. Make tagging part of the provisioning step, not an afterthought.
Related
- Ephemeral Preview URL Management and Routing — the parent guide; teardown reclaims what routing exposes.
- Tracking CI/CD Compute Costs for Platform Teams — turning the
teamlabels into chargeback reports. - Automated Preview Deployments on Pull Requests — the provisioning that must tag namespaces at creation.
- Preview Environments & Environment Parity — the section overview.