How Orphaned Neon Preview Branches Quietly Inflated Our Invoice (and the Script We Used to Fix It)

By TechGeeta
How Orphaned Neon Preview Branches Quietly Inflated Our Invoice (and the Script We Used to Fix It)
4 min read

TL;DR
Neon preview branches tied to deleted GitHub branches do not auto-clean themselves. Over time, they silently accumulate and increase your monthly invoice. We discovered this the hard way. This post documents the exact operational problem, the audit mindset, and the battle-tested cleanup script we used to regain cost control—without touching production data.

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


The Hidden Cost Trap in Preview Environments

Preview environments are a competitive advantage—until they turn into financial liabilities.

Platforms like Neon make it trivial to spin up database branches per feature or PR. Combined with Git workflows and CI/CD, this feels “free” and harmless.

Reality check:
When GitHub branches are deleted, Neon preview branches often remain alive unless explicitly removed.

Result:

  • Idle databases

  • Zero traffic

  • Still billable

This is not a bug. It’s an operational gap.


The Symptom We Noticed (Too Late)

Our early warning signal wasn’t an alert—it was an unexpected invoice spike.

On investigation:

  • Dozens of preview branches existed in Neon

  • Corresponding GitHub branches were long gone

  • Some branches were weeks old, others months

At that point, manual deletion was not an option. We needed repeatable, auditable cleanup.


Design Goals for the Fix

Before writing a single line of code, we aligned on constraints:

  • Zero risk to protected branches (main, staging, production)

  • Read-before-delete (dry run first)

  • Source of truth = GitHub, not local Git

  • API-safe (rate limiting, batching)

  • Human-readable output for audits and reviews

This led to a standalone cleanup script.


The Cleanup Strategy (High Level)

  1. Fetch all Neon branches via API

  2. Fetch all active GitHub branches from origin

  3. Normalize naming (preview/* handling)

  4. Diff the two sets

  5. Flag orphaned Neon branches

  6. Delete them only after confirmation


The Script (Used in Production)

⚠️ Important
Nothing below is redacted or simplified. This is the exact script we used internally.
Run with DRY_RUN=true first.

#!/bin/bash

# Cleanup Script for Orphaned Neon Branches # This script deletes Neon branches that no longer exist in GitHub

set -e

# Load environment variables from .env file if it exists if [ -f ".env.neon" ]; then
  source .env.neon
fi

# Configuration
PROTECTED_BRANCHES=("main" "staging" "production")
DRY_RUN=${DRY_RUN:-true}  # Set to false to actually delete
BATCH_SIZE=10  # Number of branches to delete before pausing
DELAY_BETWEEN_BATCHES=2  # Seconds to wait between batches

# Colors for output
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
BLUE='\033[0;34m'
NC='\033[0m' # No Color

# Check required environment variables if [ -z "$NEON_API_KEY" ]; then
  echo -e "${RED}❌ Error: NEON_API_KEY environment variable is not set${NC}"
  exit 1
fi

if [ -z "$NEON_PROJECT_ID" ]; then
  echo -e "${RED}❌ Error: NEON_PROJECT_ID environment variable is not set${NC}"
  exit 1
fi

# Check if jq is installed if ! command -v jq &> /dev/null; then
  echo -e "${RED}❌ Error: jq is required but not installed${NC}"
  exit 1
fi

echo -e "${BLUE}🔍 Fetching branches from Neon...${NC}"

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

NEON_BRANCHES=$(echo "$NEON_RESPONSE" | jq -r '.branches[] | "\(.id)|\(.name)"')

echo -e "${BLUE}🔍 Fetching branches from GitHub...${NC}"

GITHUB_BRANCHES=$(git ls-remote --heads origin | awk '{print $2}' | sed 's#refs/heads/##')

is_protected() {
  local branch=$1
  for protected in "${PROTECTED_BRANCHES[@]}"; do
    [[ "$branch" == "$protected" ]] && return 0
  done
  return 1
}

github_branch_exists() {
  local neon_branch=$1
  local branch_without_prefix="${neon_branch#preview/}"
  echo "$GITHUB_BRANCHES" | grep -q "^${branch_without_prefix}$"
}

TO_DELETE=()

while IFS='|' read -r branch_id branch_name; do
  is_protected "$branch_name" && continue
  github_branch_exists "$branch_name" && continue
  TO_DELETE+=("$branch_id|$branch_name")
done <<< "$NEON_BRANCHES"

if [ "${#TO_DELETE[@]}" -eq 0 ]; then
  echo "✅ No orphaned branches found"
  exit 0
fi

if [ "$DRY_RUN" = true ]; then
  echo "🏃 DRY RUN — branches that would be deleted:"
  printf '%s\n' "${TO_DELETE[@]}"
  exit 0
fi

read -p "Type 'DELETE' to confirm: " confirmation
[ "$confirmation" != "DELETE" ] && exit 0

for item in "${TO_DELETE[@]}"; do
  IFS='|' read -r branch_id branch_name <<< "$item"
  curl -s -X DELETE \
    -H "Authorization: Bearer $NEON_API_KEY" \
    "https://console.neon.tech/api/v2/projects/$NEON_PROJECT_ID/branches/$branch_id"
done

echo "✅ Cleanup complete" 

What This Gave Us Immediately

  • 📉 Instant reduction in billable branches

  • 🔍 Clear visibility into infra drift

  • 🧠 A repeatable audit mechanism

  • 🛡️ Zero risk to production

Most importantly, it exposed a deeper truth:

Manual cleanup is damage control—not prevention.


What’s Next (Automation)

This script solves the existing mess.

The real fix is eliminating the problem at the source—by automatically deleting Neon branches the moment a GitHub branch is deleted.

That’s exactly what we implemented next using GitHub Actions, and we’ll cover it in the follow-up post.

➡️ Coming next:
Zero-Touch Neon Cost Control: Auto-Deleting Preview DBs on GitHub Branch Deletion

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.