How to Safely Use a Shared DB in Preview Environments — Per-PR Schema Isolation and Seed Data Automation
To be honest, I've lost an entire day to database issues in a Preview environment before. A colleague's PR migration wiped out a table in my Preview environment. Haven't we all sent that Slack message at least once — "Who DROP'd my table?"
Preview environments are isolated deployment environments that spin up automatically for each PR. While the frontend and backend separate cleanly, the database often remains a minefield where migrations conflict under the guise of being "shared." This article covers three strategies for running completely isolated environments per PR on a single shared database, including seed data automation, with real code examples. With Neon's expanding branching ecosystem, the release of DBLab 4.0, and other tools in this space maturing rapidly, the choices have become clear depending on team size and data volume.
Core Concepts
Why Preview Environments Hit a Wall at the Database
Preview Environment: An isolated temporary deployment environment that is automatically provisioned whenever a PR is created. It provides the full stack — frontend, backend, and database — via a unique URL, allowing code changes to be verified under production-like conditions.
When you open a PR on Vercel or Netlify, the frontend spins up with a unique URL. The problem is the backend, especially the database. If PR-A adds a column to the users table and PR-B deletes a column from the same table? If both are pointing at the same DB, whichever merges first breaks the other. After experiencing this several times myself, I finally thought, "I need to seriously consider DB isolation."
Which Strategy Should Our Team Use?
This is a question that frequently comes up in practice, and you can make a quick decision based on the following criteria:
- If seed data is sufficient and additional costs are difficult to justify → PostgreSQL schema isolation
- If you need to test with production data and prefer a managed service → Neon DB branching
- If you need TB-scale production data and have self-hosting infrastructure capabilities → DBLab Thin Clone
| Strategy | Core Principle | Provisioning Speed | Includes Production Data | Cost |
|---|---|---|---|---|
| PostgreSQL Schema Isolation | Dynamic search_path switching |
Instant | ❌ (Seed data only) | Free |
| DB Branching (Neon) | Copy-on-Write branches | Seconds | ✅ | Pay-as-you-go (approximately $0.5–2/month per branch, varies by compute usage) |
| Thin Clone (DBLab) | ZFS/LVM snapshots | Seconds | ✅ (TB-scale too) | Infrastructure costs only |
This article uses Prisma ORM as an example, but the schema isolation, branching, and seed automation patterns themselves are applicable regardless of ORM. The same principles apply whether your team uses Drizzle, TypeORM, or raw SQL.
Practical Implementation
Example 1: Lightweight Isolation Based on PostgreSQL Schemas
This pattern is useful when you don't want to spend on external services, or when you want to apply it directly to an existing PostgreSQL instance. Honestly, for small teams, this alone is sufficient.
In PostgreSQL, schemas serve as namespaces within a single database. The pr_123 schema and pr_456 schema live in the same DB but are completely unaware of each other's tables. This is a built-in feature that PostgreSQL has supported for over 20 years, requiring no separate external services.
# .github/workflows/preview-schema.yml
name: Schema Isolation
on:
pull_request:
types: [opened, synchronize]
jobs:
setup:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Dependencies
run: pnpm install
- name: Create Schema & Migrate
run: |
SCHEMA_NAME="pr_${{ github.event.number }}"
# 스키마 생성
psql "$DATABASE_URL" -c "CREATE SCHEMA IF NOT EXISTS ${SCHEMA_NAME};"
# 스키마가 지정된 DATABASE_URL로 마이그레이션 및 시드 실행
DATABASE_URL="${DATABASE_URL}?schema=${SCHEMA_NAME}" \
npx prisma migrate deploy
DATABASE_URL="${DATABASE_URL}?schema=${SCHEMA_NAME}" \
npx prisma db seed
env:
DATABASE_URL: ${{ secrets.SHARED_DATABASE_URL }}# .github/workflows/preview-schema-cleanup.yml
name: Cleanup Preview Schema
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Drop Schema
run: |
SCHEMA_NAME="pr_${{ github.event.number }}"
psql "$DATABASE_URL" -c "DROP SCHEMA IF EXISTS ${SCHEMA_NAME} CASCADE;"
env:
DATABASE_URL: ${{ secrets.SHARED_DATABASE_URL }}On the Vercel side, if you set the DATABASE_URL environment variable with ?schema=pr_${VERCEL_GIT_PULL_REQUEST_ID} appended, the Preview deployment will automatically point to the isolated schema.
search_pathcaveat: In environments using connection poolers like PgBouncer,search_pathcan leak between sessions. In such cases, rather than using the?schema=query parameter approach, it's safer to explicitly callSET search_path TO pr_xxx;before each query execution, or specify a session initialization query in the pooler configuration. I've also spent time debugging this in a PgBouncer environment without knowing about it.
Prisma
?schema=parameter: Prisma supports settingsearch_pathvia the?schema=parameter in the PostgreSQL connection string. This parameter is correctly applied when runningprisma migrate deployas well. However, behavior may vary depending on your Prisma version, so I recommend checking the release notes for the version you're using.
The appeal of this approach is that there's absolutely no additional cost. However, since you can't replicate production data, the quality of your seed data determines test reliability. For frontend developers, the initial schema setup can be handled once by a DBA or DevOps engineer, and afterward you just use it by changing the DATABASE_URL environment variable.
| Step | Action | Key Point |
|---|---|---|
| Schema creation | CREATE SCHEMA IF NOT EXISTS |
Namespace separation based on PR number |
| Migration | prisma migrate deploy |
Target schema specified via ?schema= parameter |
| Seed data | prisma db seed |
Idempotent initial data injection |
| Cleanup | DROP SCHEMA CASCADE on PR close |
Prevents resource leaks |
Example 2: Neon + GitHub Actions + Vercel — Fully Automated Branching
If you need an environment that includes production data, this combination offers the best accessibility. I've been using this combination in a recent project — when a PR opens, a Neon branch is automatically created, Prisma migrations are applied, and seed data is injected.
Copy-on-Write (CoW): A technique that doesn't actually copy the original data, but only writes newly the pages where changes occur. Even if 10 branches share a 1TB original, only the portions changed in each branch consume additional storage space.
# .github/workflows/preview-db.yml
name: Preview Database Branch
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
setup-db:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: '20'
- name: Install Dependencies
run: pnpm install
- uses: neondatabase/create-branch-action@v5
id: create-branch
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch_name: preview/pr-${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}
parent: main
- name: Run Migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ steps.create-branch.outputs.db_url }}
- name: Seed Data
run: npx prisma db seed
env:
DATABASE_URL: ${{ steps.create-branch.outputs.db_url }}# .github/workflows/preview-cleanup.yml
name: Cleanup Preview Branch
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- uses: neondatabase/delete-branch-action@v3
with:
project_id: ${{ secrets.NEON_PROJECT_ID }}
branch: preview/pr-${{ github.event.number }}
api_key: ${{ secrets.NEON_API_KEY }}| Step | Action | Key Point |
|---|---|---|
| Branch creation | CoW branch from main |
Includes production data, completes in seconds |
| Migration | prisma migrate deploy |
Only the PR's new migrations applied in order |
| Seed data | prisma db seed |
Test accounts, sample data injection |
| Cleanup | Branch deletion on PR close | Prevents resource leaks |
On the Vercel side, setting the DATABASE_URL environment variable to the Neon branch's connection string will make the Preview deployment automatically point to the isolated DB. I've experienced Neon billing ramping up faster than expected, so I strongly recommend enabling inactive branch auto-deletion policies and compute auto-suspend settings.
Example 3: DBLab Thin Clone — Even Terabytes in Seconds
DBLab Engine leverages ZFS or LVM Copy-on-Write snapshots. Even if the production DB is 5TB, clone creation takes the same few seconds. The key point is that even if 10 clones share a 1TB original, total disk usage remains approximately 1TB.
# .github/workflows/preview-dblab.yml
name: Preview with DBLab Clone
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
setup-db:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Create Thin Clone
id: clone
run: |
RESPONSE=$(curl -s -X POST "$DBLAB_API_URL/clone" \
-H "Authorization: Bearer $DBLAB_TOKEN" \
-H "Content-Type: application/json" \
-d '{
"id": "pr-${{ github.event.number }}",
"protected": false,
"db": {
"username": "preview_user",
"password": "'"$CLONE_PASSWORD"'"
}
}')
echo "db_url=$(echo $RESPONSE | jq -r '.db.connStr')" >> $GITHUB_OUTPUT
env:
DBLAB_API_URL: ${{ secrets.DBLAB_API_URL }}
DBLAB_TOKEN: ${{ secrets.DBLAB_TOKEN }}
CLONE_PASSWORD: ${{ secrets.CLONE_PASSWORD }}
- name: Run Migrations
run: npx prisma migrate deploy
env:
DATABASE_URL: ${{ steps.clone.outputs.db_url }}
- name: Seed Data
run: npx prisma db seed
env:
DATABASE_URL: ${{ steps.clone.outputs.db_url }}# .github/workflows/preview-dblab-cleanup.yml
name: Cleanup DBLab Clone
on:
pull_request:
types: [closed]
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Delete Clone
run: |
curl -s -X DELETE "$DBLAB_API_URL/clone/pr-${{ github.event.number }}" \
-H "Authorization: Bearer $DBLAB_TOKEN"
env:
DBLAB_API_URL: ${{ secrets.DBLAB_API_URL }}
DBLAB_TOKEN: ${{ secrets.DBLAB_TOKEN }}This approach has an initial setup barrier since you need to operate ZFS/LVM infrastructure yourself. Once a DBA or DevOps engineer sets up the DBLab Engine, developers only need to use the DATABASE_URL environment variable from the workflow above, making role separation natural.
Common: Idempotent Seed Scripts
Regardless of which isolation strategy you use, seed data automation is universally needed. Here's the pattern I settled on after making mistakes multiple times.
// prisma/seed.ts
import { PrismaClient } from '@prisma/client';
const prisma = new PrismaClient();
async function main() {
const adminUser = await prisma.user.upsert({
where: { email: 'admin@example.com' },
update: {},
create: {
email: 'admin@example.com',
name: 'Test Admin',
role: 'ADMIN',
},
});
const existingOrg = await prisma.organization.findUnique({
where: { slug: 'test-org' },
});
if (!existingOrg) {
await prisma.organization.create({
data: {
name: 'Test Organization',
slug: 'test-org',
members: { connect: { id: adminUser.id } },
},
});
}
console.log('Seed completed successfully');
}
main()
.catch((e) => {
console.error(e);
process.exit(1);
})
.finally(() => prisma.$disconnect());Idempotency: The property where performing the same operation multiple times produces the same result. In seed scripts, this is implemented using
upsertpatterns or existence-check logic.
Adding the configuration below to package.json allows execution via npx prisma db seed. Since tsx needs to be included in devDependencies, I recommend running pnpm add -D tsx beforehand.
{
"prisma": {
"seed": "tsx prisma/seed.ts"
},
"devDependencies": {
"tsx": "^4.0.0"
}
}| Pattern | Use Case | Caveats |
|---|---|---|
upsert |
Ensuring idempotency for single records | where condition must be a unique field |
findFirst + conditional creation |
Complex relational data | Recommended to wrap in a transaction |
createMany + skipDuplicates |
Bulk seed data | Only supported in PostgreSQL |
Pros and Cons Analysis
Advantages
| Item | Details |
|---|---|
| PostgreSQL Schema Isolation | Zero cost, no external dependencies, configurable in 5 minutes, works on all PostgreSQL hosting |
| Neon DB Branching | Test with production data, official GitHub/Vercel integration, built-in data masking |
| DBLab Thin Clone | TB-scale clones in seconds, no vendor lock-in with self-hosting, only infrastructure costs |
Disadvantages and Caveats
| Item | Disadvantages | Mitigation |
|---|---|---|
| PostgreSQL Schema | Cannot replicate production data, management complexity with thousands of schemas, search_path leakage possible in PgBouncer environments |
Improve seed data quality, auto-cleanup stale schemas via cron, configure connection pooler session initialization |
| Neon | Cloud vendor lock-in, billing based on branch count and compute time, costs accumulate with idle branches | Inactive branch auto-deletion policies, leverage compute auto-suspend |
| DBLab | Requires ZFS/LVM infrastructure operation, high initial setup barrier, requires dedicated DevOps personnel | Leverage official Docker images, separate roles with infrastructure team |
| Common: Sensitive Data | PII exposure risk when replicating production data | Use Neon masking branches, or build a separate anonymization pipeline |
PII (Personally Identifiable Information): Information that can identify an individual (name, email, phone number, etc.). Masking is mandatory when replicating production data to Preview environments.
Most Common Mistakes in Practice
-
Forgetting cleanup after PR close — Branches or schemas keep accumulating, and resource costs snowball. It's best to always connect a cleanup job to the
pull_request: [closed]event. I once discovered dozens of orphaned schemas a month later and had to scramble to create a cleanup script. -
Using only
INSERTin seed scripts — Duplicate data accumulates every time CI re-runs. It's important to ensure idempotency withupsertorON CONFLICT DO NOTHINGpatterns. -
Using
prisma migrate devin Preview environments as you would in development —migrate devis for detecting schema changes and generating new migration files, while in Preview and production environments, it's safer to usemigrate deploywhich only applies existing migrations. Once you've had the experience of runningmigrate devin Preview CI and tangling up the migration history, you'll never forget the difference.
Conclusion
Having an isolated database environment for each PR is no longer a luxury exclusive to large enterprises — it's a practical strategy that most teams can quickly adopt with the right tool choices. However, since each strategy requires different capabilities and infrastructure, the key is choosing the right starting point for your team's situation. I've settled on a combination of starting with schema isolation for small projects and transitioning to Neon branching when production data becomes necessary.
Three steps you can start with right now:
-
If you chose PostgreSQL schema isolation — You can take the first step by adding
CREATE SCHEMA IF NOT EXISTS pr_${PR_NUMBER};to your CI pipeline and connectingDROP SCHEMA IF EXISTS ... CASCADE;to a cleanup workflow. If you chose Neon — You can get started just by addingneondatabase/create-branch-action@v5anddelete-branch-action@v3to your GitHub Actions. -
Refactor your seed script to be idempotent — Replace
createwithupsertin your existing seed file and add theprisma.seedconfiguration topackage.json. Try runningnpx prisma db seed3 times in a row and verify that it produces the same result without errors. -
Add cleanup automation — Add a workflow to your GitHub Actions that detects the
pull_request: [closed]event and performs schema DROP or branch deletion. This single step is the most important safeguard against resource leaks.
Next article: Building a pipeline that safely masks and provides production data to Preview environment databases — Comparing Neon masking branches and custom anonymization scripts
References
- A database for every preview environment using Neon, GitHub Actions, and Vercel | Neon Blog
- Automate branching with GitHub Actions | Neon Docs
- Create Environments with Masked Production Data Using Neon Branches | Neon Blog
- Practical Guide to Database Branching | Neon Blog
- Full-stack preview environments with DBLab 4.0 | PostgresAI
- DBLab Engine | GitHub
- Stop Using Database Branching for PR Previews: Postgres Schemas Are Enough | DEV Community
- Database branching: three-way merge for schema changes | PlanetScale
- Modern Database CI/CD with Atlas | AtlasGo
- Seeding | Prisma Documentation
- Preview Environments with PostgreSQL: Per-PR Database Isolation | Bunnyshell