Zero-Touch Neon Cost Control: Auto-Deleting Preview Databases on GitHub Branch Removal

By TechGeeta
Zero-Touch Neon Cost Control: Auto-Deleting Preview Databases on GitHub Branch Removal
5 min read

TL;DR
Manual cleanup fixes yesterday’s mess. Automation prevents tomorrow’s invoice shock. This post documents how we wired GitHub branch deletion events directly to Neon preview database cleanup, ensuring zero orphaned branches, zero manual intervention, and predictable infrastructure costs.

This post focuses on preventive automation. For one-time cleanup and auditing, see Part 1.


Context: Why Manual Cleanup Is Not Enough

In the previous post, we covered how orphaned preview branches in Neon silently inflated our invoice—and how we cleaned them up safely with a script.

That solved the symptom.

This post solves the systemic failure.

As long as preview DB deletion is manual, cost leakage is guaranteed.

Modern teams move fast. Branches are created and deleted daily. Expecting humans to remember infra cleanup is operational fantasy.


Design Principle: GitHub Is the Source of Truth

If a GitHub branch no longer exists, its preview database should not exist either.

So the rule became simple:

Branch deleted in GitHub → Preview DB deleted in Neon

No dashboards.
No cron jobs.
No human decisions.

Just event-driven automation.


Why GitHub Actions Was the Right Control Plane

We evaluated multiple approaches. GitHub Actions won decisively:

  • Native access to branch lifecycle events

  • Secure secrets management

  • Zero external infra

  • Auditable execution logs

  • Team-wide visibility

Using GitHub Actions also ensured the solution stayed close to the code, not bolted on as an afterthought.


The Event That Matters: delete

GitHub emits a delete event whenever:

  • A branch is deleted

  • A tag is deleted

We explicitly scope this workflow to:

  • Branches only

  • Never tags

This avoids accidental or undefined behavior.


Guardrails First: Never Touch Protected Branches

Before touching Neon, the workflow enforces hard guardrails:

  • main

  • staging

  • production

If any of these are deleted (accidentally or otherwise), the workflow exits immediately.

No API calls.
No side effects.
No surprises.

This is not optional. This is table stakes for production safety.


The Automation Workflow (Production-Grade)

⚠️ Important
The workflow below is shared as-is. Nothing is removed, simplified, or abstracted.

name: Cleanup Neon DB on Branch Delete (Protected)

on:
  delete:

concurrency:
  group: neon-cleanup-${{ github.event.ref }}
  cancel-in-progress: false

jobs:
  delete-neon-branch:
    runs-on: ubuntu-latest
    if: github.event.ref_type == 'branch'

    steps:
      - name: Guardrail - Prevent deletion of protected branches.
        id: guard
        run: |
          PROTECTED_BRANCHES=("main" "staging" "production")
          BRANCH="${{ github.event.ref }}"

          for protected in "${PROTECTED_BRANCHES[@]}"; do
            if [[ "$BRANCH" == "$protected" ]]; then
              echo "❌ Protected branch '$BRANCH' - skipping Neon cleanup"
              exit 0
            fi
          done

          echo "✅ Branch '$BRANCH' eligible for Neon cleanup"
          echo "branch_name=$BRANCH" >> $GITHUB_OUTPUT

      - name: Install jq for JSON parsing
        if: steps.guard.outputs.branch_name != ''
        run: sudo apt-get update && sudo apt-get install -y jq

      - name: Delete Neon DB Branch
        if: steps.guard.outputs.branch_name != ''
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
          PROJECT_ID: ${{ secrets.NEON_PROJECT_ID }}
          BRANCH_NAME: ${{ github.event.ref }}
        run: |
          set +e
          echo "🔍 Looking up Neon branch ID for: $BRANCH_NAME"

          RESPONSE=$(curl -s -X GET \
            -H "Authorization: Bearer $NEON_API_KEY" \
            -H "Accept: application/json" \
            "https://console.neon.tech/api/v2/projects/$PROJECT_ID/branches")

          BRANCH_ID=$(echo "$RESPONSE" | jq -r --arg name "$BRANCH_NAME" '.branches[] | select(.name == $name) | .id')

          if [ -z "$BRANCH_ID" ] || [ "$BRANCH_ID" == "null" ]; then
            BRANCH_ID=$(echo "$RESPONSE" | jq -r --arg name "preview/$BRANCH_NAME" '.branches[] | select(.name == $name) | .id')
          fi

          [ -z "$BRANCH_ID" ] && exit 0

          curl -s -X DELETE \
            -H "Authorization: Bearer $NEON_API_KEY" \
            "https://console.neon.tech/api/v2/projects/$PROJECT_ID/branches/$BRANCH_ID" 

Key Engineering Decisions (That Actually Matter)

1️⃣ Exact-Match First, Prefix Second

We attempt:

  1. Exact branch name

  2. preview/branch-name

This mirrors how most CI systems (Vercel, custom pipelines) name Neon branches.


2️⃣ Graceful Failure by Default

If the Neon branch:

  • Never existed

  • Was already deleted

  • Fails lookup

The workflow exits cleanly.

No red pipelines.
No alert fatigue.
No human intervention required.


3️⃣ Concurrency Control

The concurrency block ensures:

  • No race conditions

  • No double deletes

  • Predictable behavior under parallel branch deletions

This is critical in active repositories.


What This Gave Us Long-Term

  • 🧾 Zero orphaned preview branches

  • 💸 Predictable Neon invoices

  • 🔒 Strong safety guarantees

  • 🧠 One less thing engineers need to remember

Most importantly, we converted cost control into code.


Manual vs Automated Cleanup (Clear Distinction)

AspectManual ScriptGitHub Action
Fix existing mess
Prevent future leaks
Human involvementRequiredNone
ScalabilityLimitedUnlimited
Operational maturityMediumHigh

They are complementary—not substitutes.


Final Takeaway

Preview environments are powerful.
Unmanaged preview environments are expensive.

If your infrastructure lifecycle is not event-driven, you are paying a tax you don’t need to pay.

Automation is not an optimization. It’s governance.

Stay Updated with Our Latest News

Subscribe to our newsletter and be the first to know about our latest projects, blog posts, and industry insights.