Published on

Database Branching — Development Workflows With Neon, PlanetScale, and Branch-Per-PR

Authors

Introduction

Database branching (like git branches for databases) enables isolation during development. Neon (Postgres) and PlanetScale (MySQL) provide branching natively. Test schema changes on a branch before merging to main. This post covers branching workflows, preview environments, and CI integration.

Database Branching Concept

A database branch is a logical copy of the main schema and (optionally) data. Each branch is isolated; changes don't affect other branches until merged.

Benefits:

  • Safe testing: Test migrations before production
  • Parallel development: Multiple features work on different schemas
  • Zero-downtime deploy: Migration tested on branch, zero risk on main
  • Compliance: Dev branches can mask sensitive data

Workflow:

main branch (production data)
feature/payment branch (dev data, PII masked)
  ├── Test migration (add users.kyc_status column)
  ├── Run integration tests
  └── Merge → main

Neon Branch Per PR for Isolated Testing

Neon (Postgres-compatible) auto-creates branches per PR. Each branch has its own database URL.

// setup: Create branch per PR using GitHub Actions
name: Create Neon Branch

on:
  pull_request:
    types: [opened, synchronize]

jobs:
  create-branch:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Create Neon branch
        id: create-branch
        run: |
          BRANCH_NAME="pr-${{ github.event.pull_request.number }}"
          
          # Create branch using Neon API
          RESPONSE=$(curl -s -X POST https://console.neon.tech/api/v2/projects/${{ secrets.NEON_PROJECT_ID }}/branches \
            -H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
            -H "Content-Type: application/json" \
            -d '{
              "branch": {
                "name": "'$BRANCH_NAME'",
                "parent_id": "main"
              }
            }')
          
          BRANCH_ID=$(echo $RESPONSE | jq -r '.branch.id')
          DB_URL=$(echo $RESPONSE | jq -r '.branch.connection_uri')
          
          echo "branch_id=$BRANCH_ID" >> $GITHUB_OUTPUT
          echo "db_url=$DB_URL" >> $GITHUB_OUTPUT
      
      - name: Comment PR with database URL
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '🗄️ Database branch created: `${{ steps.create-branch.outputs.branch_id }}`\n\nDatabase URL: `${{ steps.create-branch.outputs.db_url }}`'
            })

      - name: Save database URL to environment
        run: |
          echo "DATABASE_URL=${{ steps.create-branch.outputs.db_url }}" >> $GITHUB_ENV

      - name: Run migrations on branch
        run: |
          npm run migrate:up
      
      - name: Run integration tests
        run: |
          npm run test:integration

  cleanup:
    runs-on: ubuntu-latest
    if: github.event.action == 'closed'
    steps:
      - name: Delete Neon branch
        run: |
          BRANCH_NAME="pr-${{ github.event.pull_request.number }}"
          
          curl -X DELETE https://console.neon.tech/api/v2/projects/${{ secrets.NEON_PROJECT_ID }}/branches/$BRANCH_NAME \
            -H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}"

PlanetScale Non-Blocking Schema Changes

PlanetScale (MySQL-compatible) supports non-blocking schema changes via Deploy Requests. No locking; changes apply gradually.

# .github/workflows/deploy-request.yml
name: Deploy Database Change

on:
  pull_request:
    paths:
      - 'migrations/**'
      - 'schema/**'

jobs:
  schema-change:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3

      - name: Read migration file
        id: migration
        run: |
          MIGRATION_FILE=$(ls -1 migrations/$(date +%Y%m%d)*.sql | head -1)
          MIGRATION_SQL=$(cat $MIGRATION_FILE)
          echo "migration<<EOF" >> $GITHUB_OUTPUT
          echo "$MIGRATION_SQL" >> $GITHUB_OUTPUT
          echo "EOF" >> $GITHUB_OUTPUT

      - name: Create PlanetScale deploy request
        id: deploy-req
        run: |
          # Create deploy request (non-blocking change)
          RESPONSE=$(curl -s -X POST https://api.planetscale.com/v1/organizations/${{ secrets.PLANETSCALE_ORG }}/databases/${{ secrets.PLANETSCALE_DB }}/deploy-requests \
            -H "Authorization: Bearer ${{ secrets.PLANETSCALE_API_TOKEN }}" \
            -H "Content-Type: application/json" \
            -d '{
              "title": "PR #${{ github.event.pull_request.number }}",
              "description": "${{ github.event.pull_request.title }}",
              "branch": "feature-branch-${{ github.event.pull_request.number }}",
              "sql": "${{ steps.migration.outputs.migration }}"
            }')
          
          DEPLOY_ID=$(echo $RESPONSE | jq -r '.id')
          echo "deploy_id=$DEPLOY_ID" >> $GITHUB_OUTPUT

      - name: Comment PR with deploy request
        uses: actions/github-script@v6
        with:
          script: |
            github.rest.issues.createComment({
              issue_number: context.issue.number,
              owner: context.repo.owner,
              repo: context.repo.repo,
              body: '✅ PlanetScale Deploy Request created: ${{ steps.deploy-req.outputs.deploy_id }}\n\nReview at: https://app.planetscale.com'
            })

Branch-Based Preview Environments

Each PR gets its own preview environment with isolated database. Preview stays live while PR is open.

# docker-compose.preview.yml - Isolated preview environment
version: '3.8'

services:
  postgres:
    image: postgres:15
    environment:
      POSTGRES_PASSWORD: password
      POSTGRES_DB: preview_db_pr_${{ github.event.pull_request.number }}
    volumes:
      - ./schema.sql:/docker-entrypoint-initdb.d/init.sql
    ports:
      - "5432:5432"

  app:
    build:
      context: .
      dockerfile: Dockerfile.preview
    environment:
      DATABASE_URL: postgres://postgres:password@postgres:5432/preview_db_pr_${{ github.event.pull_request.number }}
      NODE_ENV: preview
    ports:
      - "3000:3000"
    depends_on:
      - postgres

  # E2E tests against preview
  e2e:
    image: playwright:v1
    environment:
      BASE_URL: http://app:3000
      DATABASE_URL: postgres://postgres:password@postgres:5432/preview_db_pr_${{ github.event.pull_request.number }}
    volumes:
      - ./tests:/tests
    command: npx playwright test
    depends_on:
      - app

Deploy preview:

# Automatically deploy on PR open
# Vercel/Netlify handle this for frontend
# For backend: use Docker Compose + cloud provider (AWS ECS, GCP Cloud Run)

# Example: Deploy to AWS ECS
aws ecs create-service \
  --cluster preview-cluster \
  --service-name preview-pr-123 \
  --task-definition app-preview \
  --environment "DATABASE_URL=postgres://..." \
  --load-balancers "targetGroupArn=...,containerName=app,containerPort=3000"

Data Masking for Dev Branches

Dev branches should NOT contain production PII. Mask sensitive data automatically.

-- mask-sensitive-data.sql - Anonymize before branching
UPDATE users SET
  email = CONCAT('user_', user_id, '@dev.example.com'),
  password_hash = 'bcrypt:$2a$12$...',
  phone = '555-0000',
  ssn = '000-00-0000',
  date_of_birth = '2000-01-01'
WHERE environment = 'development';

UPDATE orders SET
  credit_card_last4 = '0000',
  credit_card_expiry = '12/99'
WHERE environment = 'development';

Automate masking on branch creation:

// mask.ts - Mask data after branch created
import { Pool } from 'pg';

export async function maskSensitiveData(dbUrl: string) {
  const pool = new Pool({ connectionString: dbUrl });

  try {
    await pool.query(`
      UPDATE users SET
        email = 'user_' || id || '@dev.test',
        phone = '555-0000',
        ssn = NULL,
        date_of_birth = '2000-01-01'
      WHERE environment_type = 'development';
    `);

    await pool.query(`
      UPDATE payment_methods SET
        card_last4 = '0000',
        card_expiry = '12/99',
        card_number = NULL
      WHERE environment_type = 'development';
    `);

    console.log('Data masked successfully');
  } finally {
    await pool.end();
  }
}

// Call from GitHub Action after creating branch
maskSensitiveData(process.env.DATABASE_URL);

Branch Lifecycle (Create → Test → Merge → Delete)

graph LR
  A["PR opened<br/>Create branch"] --> B["Run migrations<br/>Run tests"]
  B --> C{"Tests pass?"}
  C -->|No| D["Fix and push<br/>Re-run tests"]
  D --> C
  C -->|Yes| E["Merge PR<br/>Apply to main"]
  E --> F["Delete branch<br/>Clean up"]

CI Integration for Migration Testing

Test every migration before production deployment.

# .github/workflows/test-migrations.yml
name: Test Migrations

on:
  pull_request:
    paths:
      - 'migrations/**'

jobs:
  test-migrations:
    runs-on: ubuntu-latest
    
    services:
      postgres:
        image: postgres:15
        env:
          POSTGRES_PASSWORD: password
        options: >-
          --health-cmd pg_isready
          --health-interval 10s
          --health-timeout 5s
          --health-retries 5

    steps:
      - uses: actions/checkout@v3

      - name: Run migrations
        env:
          DATABASE_URL: postgres://postgres:password@localhost:5432/test_db
        run: |
          npm run migrate:up
          npm run migrate:verify

      - name: Run schema tests
        env:
          DATABASE_URL: postgres://postgres:password@localhost:5432/test_db
        run: |
          npm run test:schema

      - name: Test rollback
        env:
          DATABASE_URL: postgres://postgres:password@localhost:5432/test_db
        run: |
          npm run migrate:down
          npm run migrate:up

Checklist

  • Set up automatic branch creation on PR open (GitHub Actions)
  • Run migrations on branch before merging
  • Implement data masking for dev branches (remove PII)
  • Test rollback capability for every migration
  • Use Neon for Postgres or PlanetScale for MySQL
  • Deploy preview environment per PR
  • Document branch naming convention
  • Set TTL for preview branches (auto-delete after 30 days)
  • Monitor branch cleanup to prevent orphaned resources
  • Integrate migration testing into CI/CD pipeline

Conclusion

Database branching accelerates development and reduces production risk. Each PR gets isolated testing with its own schema. Mask sensitive data to maintain compliance. Automate branch creation and cleanup via CI/CD. Combined with preview environments, branches enable rapid iteration without sacrificing safety.