Skip to main content

Ephemeral Preview Environments with GitHub Actions and Vercel

June 2, 2026

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 automatically

Vercel'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"
          fi

Preview 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:

  1. Auto-destroying environments when PRs close (the teardown workflow above).
  2. Setting compute limits — Vercel preview deployments use the same limits as production.
  3. Neon's branching is free on the free tier up to 10 branches.
  4. 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.

Recommended Posts