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

- Name
- Sanjeev Sharma
- @webcoderspeed1
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
- Neon Branch Per PR for Isolated Testing
- PlanetScale Non-Blocking Schema Changes
- Branch-Based Preview Environments
- Data Masking for Dev Branches
- Branch Lifecycle (Create → Test → Merge → Delete)
- CI Integration for Migration Testing
- Checklist
- Conclusion
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.