Tutorials
|stacknotice.com
14 min left|
0%
|2,800 words
Tutorials

CI/CD That Actually Works: GitHub Actions + Preview Deploys (2026)

The GitHub Actions workflow senior engineers use — database migrations first, preview deployments, E2E tests on preview URLs, and production gates.

May 28, 202614 min read
Share:
CI/CD That Actually Works: GitHub Actions + Preview Deploys (2026)

Most CI/CD setups are theater.

They run a linter, maybe some unit tests, then push to production regardless of what those tests found. Senior engineers build pipelines that enforce quality — and that run in a specific order so that a broken migration can never reach production.

Here's the complete setup for a Next.js SaaS in 2026.

What a real CI/CD pipeline does

A production-grade pipeline does five things, in order:

  1. Validates — types, lint, build (catches configuration errors early)
  2. Migrates — database changes run before any new code ships
  3. Previews — every PR gets a real, isolated environment
  4. Tests — E2E tests run against the preview environment, not a mock
  5. Gates — production only ships when everything passes

Most setups do step 1 and call it done.

The workflow structure

.github/
└── workflows/
    ├── ci.yml        # runs on every PR
    └── deploy.yml    # runs on push to main

Two separate workflows. ci.yml validates and tests PRs before they merge. deploy.yml migrates and deploys when code lands on main.

ci.yml — the PR workflow

name: CI
 
on:
  pull_request:
    branches: [main]
 
jobs:
  validate:
    name: Validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
 
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
 
      - run: npm ci
 
      - name: Type check
        run: npm run typecheck
 
      - name: Lint
        run: npx biome check .
 
      - name: Build (validates env vars via t3-env)
        run: npm run build
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL_PREVIEW }}
          CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY_PREVIEW }}
          NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY_PREVIEW }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY_TEST }}
          STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET_TEST }}
          NEXT_PUBLIC_APP_URL: ${{ vars.PREVIEW_APP_URL }}
 
  migrate-preview:
    name: Migrate preview database
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - name: Run migrations
        run: npx drizzle-kit migrate
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL_PREVIEW }}
 
  e2e:
    name: E2E tests on preview
    runs-on: ubuntu-latest
    needs: migrate-preview
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npx playwright install --with-deps chromium
      - name: Run E2E tests against preview URL
        run: npx playwright test
        env:
          BASE_URL: ${{ vars.PREVIEW_APP_URL }}
          TEST_USER_EMAIL: ${{ secrets.TEST_USER_EMAIL }}
          TEST_USER_PASSWORD: ${{ secrets.TEST_USER_PASSWORD }}

The needs chain enforces order: validatemigrate-previewe2e. Nothing runs out of sequence.

deploy.yml — the production workflow

name: Deploy
 
on:
  push:
    branches: [main]
 
jobs:
  validate:
    name: Validate
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - run: npm run typecheck
      - run: npx biome check .
      - name: Build
        run: npm run build
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL_PRODUCTION }}
          CLERK_SECRET_KEY: ${{ secrets.CLERK_SECRET_KEY }}
          NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY: ${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
          STRIPE_SECRET_KEY: ${{ secrets.STRIPE_SECRET_KEY }}
          STRIPE_WEBHOOK_SECRET: ${{ secrets.STRIPE_WEBHOOK_SECRET }}
          NEXT_PUBLIC_APP_URL: https://yourapp.com
 
  migrate:
    name: Migrate production database
    runs-on: ubuntu-latest
    needs: validate
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '22'
          cache: 'npm'
      - run: npm ci
      - name: Run migrations
        run: npx drizzle-kit migrate
        env:
          DATABASE_URL: ${{ secrets.DATABASE_URL_PRODUCTION }}
 
  deploy:
    name: Deploy to Vercel
    runs-on: ubuntu-latest
    needs: migrate
    steps:
      - name: Trigger Vercel deploy hook
        run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"

The deploy job only runs after migrate succeeds. Migrations always win.

Guaranteed deploy order: the key detail

The naive approach — letting both Vercel's auto-deploy and GitHub Actions react to the same push event — has a race condition. Both start at the same time. Sometimes Vercel deploys before migrations finish.

The safe setup:

  1. Disable Vercel's automatic GitHub push deployment (Vercel dashboard → Project → Settings → Git → turn off auto-deploy on push)
  2. Add a Vercel deploy hook (Vercel dashboard → Settings → Git → Deploy Hooks → create one)
  3. Store it as VERCEL_DEPLOY_HOOK secret in GitHub
  4. Trigger it at the end of the migrate job (as shown above)

Now the sequence is guaranteed: validate → migrate → Vercel deploys. Migrations always complete first.

Vercel deploy hooks

A deploy hook is a unique URL that triggers a Vercel deployment when POSTed to. It's the cleanest way to control exactly when Vercel deploys, instead of relying on git push timing.

Preview environments per PR

Vercel creates a preview environment for every PR automatically when your GitHub repo is connected. Each preview gets a unique URL:

https://myapp-git-feat-login-yourorg.vercel.app

Configure preview environment variables separately in Vercel dashboard:

VariableProductionPreview
DATABASE_URLNeon productionNeon preview branch
CLERK_SECRET_KEYClerk productionClerk development
STRIPE_SECRET_KEYStripe live keyStripe test key

Never use production credentials in preview environments. A broken test in CI should never touch real user data.

Database branching per PR with Neon

With Neon's database branching, you can give every PR its own database branch — isolated, seeded from production schema, no shared state between PRs:

  create-db-branch:
    runs-on: ubuntu-latest
    outputs:
      branch-url: ${{ steps.branch.outputs.url }}
    steps:
      - name: Create Neon branch for this PR
        id: branch
        run: |
          URL=$(npx neonctl branches create \
            --project-id ${{ secrets.NEON_PROJECT_ID }} \
            --name pr-${{ github.event.number }} \
            --output json | jq -r '.connection_uri')
          echo "url=$URL" >> $GITHUB_OUTPUT
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}
 
  cleanup-db-branch:
    runs-on: ubuntu-latest
    if: github.event_name == 'pull_request' && github.event.action == 'closed'
    steps:
      - name: Delete Neon branch when PR closes
        run: |
          npx neonctl branches delete pr-${{ github.event.number }} \
            --project-id ${{ secrets.NEON_PROJECT_ID }}
        env:
          NEON_API_KEY: ${{ secrets.NEON_API_KEY }}

Each PR gets its own database. Migrations run against it. E2E tests hit it. When the PR closes, the branch deletes automatically.

E2E tests on the real preview URL

The value of preview environments is running E2E tests against a real deployed app — not a local server, not a mock:

// playwright.config.ts
import { defineConfig, devices } from '@playwright/test'
 
export default defineConfig({
  use: {
    baseURL: process.env.BASE_URL ?? 'http://localhost:3000',
    trace: 'on-first-retry',
  },
  projects: [
    { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
  ],
})
// tests/critical-path.spec.ts
import { test, expect } from '@playwright/test'
 
test('user can sign up and create a project', async ({ page }) => {
  // This test runs against the real preview URL
  await page.goto('/')
  await page.getByRole('link', { name: 'Get started' }).click()
 
  await page.getByLabel('Email address').fill(process.env.TEST_USER_EMAIL!)
  await page.getByLabel('Password').fill(process.env.TEST_USER_PASSWORD!)
  await page.getByRole('button', { name: 'Sign in' }).click()
 
  await expect(page).toHaveURL('/dashboard')
 
  await page.getByRole('button', { name: 'New project' }).click()
  await page.getByLabel('Project name').fill('Test Project')
  await page.getByRole('button', { name: 'Create' }).click()
 
  await expect(page.getByText('Test Project')).toBeVisible()
})

Test the critical path: log in → do the core action → verify the outcome. If this breaks in the preview, it doesn't reach production.

For a full Next.js testing guide with Vitest and Playwright.

Branch protection rules

In GitHub → repository → Settings → Branches → Add rule for main:

✅ Require a pull request before merging
✅ Require status checks to pass before merging
   Required checks: validate, migrate-preview, e2e
✅ Require branches to be up to date before merging
✅ Do not allow bypassing the above settings

With these rules, no one ships broken code to main — not even admins. The pipeline is the gatekeeper, not individual discipline.

Secrets management

Never hardcode secrets in workflow files. Use GitHub's secret store:

Repository → Settings → Secrets and variables → Actions

Organize by environment:

SecretUsed in
DATABASE_URL_PRODUCTIONdeploy.yml migrate job
DATABASE_URL_PREVIEWci.yml migrate-preview job
CLERK_SECRET_KEYProduction builds
CLERK_SECRET_KEY_PREVIEWPreview builds
STRIPE_SECRET_KEYProduction
STRIPE_SECRET_KEY_TESTPreview (test mode)
VERCEL_DEPLOY_HOOKdeploy.yml deploy job
NEON_API_KEYDatabase branching
TEST_USER_EMAILE2E tests
TEST_USER_PASSWORDE2E tests
Warning

Use vars (not secrets) for non-sensitive values like PREVIEW_APP_URL. GitHub Actions has separate stores for secrets (encrypted, never logged) and variables (visible in logs). Don't put sensitive values in vars.

npm scripts to add

{
  "scripts": {
    "typecheck": "tsc --noEmit",
    "lint": "biome check .",
    "lint:fix": "biome check --write .",
    "build": "next build",
    "migrate": "drizzle-kit migrate",
    "migrate:generate": "drizzle-kit generate",
    "test": "vitest run",
    "test:e2e": "playwright test",
    "test:e2e:ui": "playwright test --ui"
  }
}

Common mistakes

MistakeFix
Vercel auto-deploys before migrationsUse deploy hook, not auto-deploy
Same secrets for preview and productionSeparate Clerk app + Stripe test mode + Neon branch
No E2E tests on previewRun Playwright against preview URL before merge
No branch protection rulesRequired status checks in GitHub settings
Single workflow for CI and CDSeparate ci.yml (PRs) and deploy.yml (main)
run: npm run test || trueNever ignore failing tests

What to add as the team grows

Team sizeAddition
1-3 devsThe setup above
3-8 devsSlack notifications on failure, Neon branch per PR
8+ devsCanary deployments, automated rollback, staging environment

The result

Every PR gets:

  • Type checking, lint, build validation
  • Its own database branch
  • A real preview URL
  • E2E tests against that preview

Every push to main gets:

  • Migrations run first, guaranteed
  • Vercel deploys after migrations complete
  • No code ships without passing the full pipeline

The pipeline becomes the authority. Deploying stops being stressful because you've already proven the code works in a real environment — before it ever touches production.

This CI/CD setup works best when paired with the production deployment checklist and the zero-downtime migrations guide.

#nextjs#github#devops#typescript#webdev
Share:

Enjoyed this article?

Join 2,400+ developers getting weekly insights on Claude Code, React, and AI tools.

No spam. Unsubscribe anytime. By subscribing you agree to our Privacy Policy.