One of the most valuable DevOps practices for software quality is the ephemeral preview environment: a short-lived, fully-functional deployment of your application that is automatically created for each pull request, used for review and testing, and destroyed when the PR closes.
Unlike static screenshots or local development, a preview environment lets stakeholders review actual running software against real infrastructure — database included. QA can run manual and automated tests. Designers can interact with the real UI. Product managers can validate features before a single line of code merges to main.
What a Preview Environment Provides
PR LIFECYCLE WITH PREVIEW ENVIRONMENTS:
Developer opens PR
│
▼
GitHub Actions triggers
│
├── Build application
├── Spin up preview database (isolated)
├── Run migrations on preview DB
├── Deploy to Vercel preview URL
└── Comment PR with preview URL
│
▼
Stakeholders review at:
https://my-app-pr-142.vercel.app
│
▼
QA runs automated tests against preview URL
│
▼
PR merged → Preview environment torn down automaticallyVercel's Built-In Preview Deployments
Vercel automatically creates a preview deployment for every push to any non-main branch when connected to GitHub. The deployment gets a unique URL and uses the same environment variables as configured:
# .vercel/project.json (auto-created by Vercel CLI)
{
"projectId": "prj_xxx",
"orgId": "team_xxx"
}The core Vercel preview is free and works automatically. What it doesn't do out-of-the-box is:
- Provision an isolated database for the PR.
- Run database migrations on the preview database.
- Seed realistic test data.
- Run your E2E test suite against the preview URL.
Adding Isolated Preview Databases with Neon
Neon provides branching for Postgres — you can create a complete copy of your database schema and data with a single API call:
# .github/workflows/preview.yml
name: Preview Environment
on:
pull_request:
types: [opened, synchronize, reopened]
jobs:
deploy-preview:
runs-on: ubuntu-latest
outputs:
preview-url: ${{ steps.deploy.outputs.preview-url }}
db-branch-id: ${{ steps.create-db.outputs.branch-id }}
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '22' }
# Step 1: Create an isolated database branch for this PR
- name: Create Neon Database Branch
id: create-db
run: |
BRANCH_DATA=$(curl -s -X POST \
-H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
-H "Content-Type: application/json" \
-d "{\"name\": \"pr-${{ github.event.pull_request.number }}\", \"parent_id\": \"${{ vars.NEON_MAIN_BRANCH_ID }}\"}" \
"https://console.neon.tech/api/v2/projects/${{ vars.NEON_PROJECT_ID }}/branches")
BRANCH_ID=$(echo $BRANCH_DATA | jq -r '.branch.id')
CONNECTION_STRING=$(echo $BRANCH_DATA | jq -r '.connection_uris[0].connection_uri')
echo "branch-id=$BRANCH_ID" >> $GITHUB_OUTPUT
echo "DATABASE_URL=$CONNECTION_STRING" >> $GITHUB_ENV
# Step 2: Run migrations on the preview database
- name: Run Database Migrations
run: npm ci && npx prisma migrate deploy
env:
DATABASE_URL: ${{ env.DATABASE_URL }}
# Step 3: Seed with test data
- name: Seed Preview Database
run: npx tsx scripts/seed-preview.ts
env:
DATABASE_URL: ${{ env.DATABASE_URL }}
# Step 4: Deploy to Vercel with preview database URL
- name: Deploy to Vercel
id: deploy
uses: amondnet/vercel-action@v25
with:
vercel-token: ${{ secrets.VERCEL_TOKEN }}
github-token: ${{ secrets.GITHUB_TOKEN }}
vercel-org-id: ${{ secrets.VERCEL_ORG_ID }}
vercel-project-id: ${{ secrets.VERCEL_PROJECT_ID }}
scope: ${{ secrets.VERCEL_ORG_ID }}
env:
DATABASE_URL: ${{ env.DATABASE_URL }}
# Step 5: Comment PR with preview URL
- name: Comment PR with Preview URL
uses: actions/github-script@v7
with:
script: |
github.rest.issues.createComment({
issue_number: context.issue.number,
owner: context.repo.owner,
repo: context.repo.repo,
body: `## 🚀 Preview Environment Ready\n\n**Preview URL:** ${{ steps.deploy.outputs.preview-url }}\n\n**Database:** Isolated Neon branch (seeded with test data)\n\nThis environment will be destroyed when the PR closes.`
});
# Run E2E tests against the preview environment
e2e-preview:
needs: deploy-preview
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm ci
- run: npx playwright install chromium --with-deps
- name: Run E2E Tests Against Preview
run: npx playwright test
env:
BASE_URL: ${{ needs.deploy-preview.outputs.preview-url }}
- name: Upload Test Results
if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-preview-results
path: playwright-report/Cleaning Up on PR Close
# .github/workflows/teardown-preview.yml
name: Teardown Preview Environment
on:
pull_request:
types: [closed]
jobs:
teardown:
runs-on: ubuntu-latest
steps:
- name: Delete Neon Database Branch
run: |
# Find the branch by PR number
BRANCHES=$(curl -s \
-H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
"https://console.neon.tech/api/v2/projects/${{ vars.NEON_PROJECT_ID }}/branches")
BRANCH_ID=$(echo $BRANCHES | jq -r \
'.branches[] | select(.name == "pr-${{ github.event.pull_request.number }}") | .id')
if [ -n "$BRANCH_ID" ]; then
curl -s -X DELETE \
-H "Authorization: Bearer ${{ secrets.NEON_API_KEY }}" \
"https://console.neon.tech/api/v2/projects/${{ vars.NEON_PROJECT_ID }}/branches/$BRANCH_ID"
echo "Deleted database branch: $BRANCH_ID"
fiPreview Database Seeding
The preview database should have realistic but anonymized test data:
// scripts/seed-preview.ts
import { db } from '@/lib/db';
import { faker } from '@faker-js/faker';
faker.seed(42); // Deterministic seeding
async function seedPreviewDatabase() {
console.log('Seeding preview database...');
// Core test accounts with known credentials
const testAccounts = [
{ email: 'admin@preview.test', role: 'ADMIN', password: 'Preview123!' },
{ email: 'user@preview.test', role: 'USER', password: 'Preview123!' },
];
for (const account of testAccounts) {
await db.query(
'INSERT INTO users (email, role, password_hash) VALUES ($1, $2, $3) ON CONFLICT (email) DO NOTHING',
[account.email, account.role, await hashPassword(account.password)]
);
}
// Realistic-looking product catalog (50 products)
for (let i = 0; i < 50; i++) {
await db.query(
'INSERT INTO products (name, price, category, description) VALUES ($1, $2, $3, $4) ON CONFLICT DO NOTHING',
[
faker.commerce.productName(),
faker.number.float({ min: 9.99, max: 299.99, fractionDigits: 2 }),
faker.commerce.department(),
faker.commerce.productDescription(),
]
);
}
console.log('Preview database seeded successfully.');
console.log('Test accounts:');
testAccounts.forEach(a => console.log(` ${a.role}: ${a.email} / ${a.password}`));
}
seedPreviewDatabase().catch(console.error);Cost Considerations
Ephemeral preview environments add infrastructure cost. Manage this by:
- Auto-destroying environments when PRs close (the teardown workflow above).
- Setting compute limits — Vercel preview deployments use the same limits as production.
- Neon's branching is free on the free tier up to 10 branches.
- Pausing databases after inactivity using Neon's autosuspend feature.
For most teams, the cost is under $10/month in preview database charges, offset significantly by the reduction in post-merge bug discovery.
Conclusion
Ephemeral preview environments transform pull request reviews from "looking at a diff" into "reviewing working software." Stakeholders interact with real features. QA runs tests against real infrastructure. Database migrations are validated before they touch production. The environment is created automatically when the PR opens and destroyed when it closes. The engineering investment — one workflow file and a Neon API key — is repaid the first time a database migration bug is caught in the preview environment instead of in production.