Skip to main content
All articles
10 min read

CI/CD with GitHub Actions for a Next.js SaaS: One Workflow File, Explained

How a real Next.js SaaS pipeline works in GitHub Actions: affected-package Turbo filtering, cost-conscious PR-only triggers, and what gates every merge.

Why GitHub Actions over the alternatives

CircleCI, GitLab CI, Buildkite... they all work. But for a Next.js SaaS deployed on Vercel, GitHub Actions has 3 decisive advantages:

  • Native GitHub integration → status checks show up directly on PRs, no webhook to wire up
  • A generous free tier → 2,000 minutes/month on private repos, unlimited on public ones
  • A rich marketplace → 90% of the actions you need already exist

For an early-stage SaaS, GitHub Actions covers 100% of CI/CD needs without spending a cent.

What the pipeline actually gates

Every pull request into main has to pass, before it can be merged:

  1. ✅ Typecheck: zero TypeScript errors
  2. ✅ Lint: clean per ESLint
  3. ✅ Unit + security tests: Vitest, including the IDOR/RBAC/HMAC-signature suites
  4. ✅ Build: Next.js compiles every affected package
  5. ✅ E2E: Playwright on the revenue-critical flows, when that part of the code actually changed

Deployment itself isn't a step in this workflow at all: Vercel's own GitHub integration deploys whatever lands on main, and GitHub branch protection is what decides whether a PR is even allowed to land there.

The real trigger: PRs only, not every push to main

# .github/workflows/ci.yml
name: CI
 
on:
  pull_request:
    branches: [main]
    types: [opened, synchronize, reopened, ready_for_review]
  # No run on push to main (cost): branch protection requires green checks on
  # an UP-TO-DATE branch, so the PR run already validated the exact tree that
  # lands on main. The full-monorepo safety net runs on the daily cron instead.
  schedule:
    - cron: "0 5 * * *" # one full monorepo validation per day (UTC)
  workflow_dispatch:
 
concurrency:
  group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
  cancel-in-progress: true
 
permissions:
  contents: read
  pull-requests: read

Two choices worth calling out:

  • No push: main trigger. Re-running the full suite on every push to main, on top of the PR run that already validated it, doubles the CI bill for zero new information. The daily schedule cron exists precisely to catch the one thing a PR-only pipeline can miss: a full-monorepo regression from something outside the diff (a shared dependency bump, for instance).
  • concurrency with cancel-in-progress: true. A new push to the same PR cancels whatever run was already in flight for it. Without this, three quick commits in a row burn three full runs instead of one.

The core job: lint and typecheck, deliberately NOT in parallel

lint-and-typecheck:
  runs-on: ubuntu-latest
  timeout-minutes: 20
  steps:
    - uses: actions/checkout@v4
      with:
        fetch-depth: 0 # needed by Turbo's "affected" filter (diff vs origin/main)
    - run: git fetch --no-tags origin main
    - uses: pnpm/action-setup@v5
    - uses: actions/setup-node@v5
      with:
        node-version: "22"
        cache: "pnpm"
    - uses: actions/cache@v4
      with:
        path: .turbo
        key: turbo-${{ runner.os }}-lint-${{ github.sha }}
        restore-keys: turbo-${{ runner.os }}-lint-
    - run: pnpm install --frozen-lockfile
    - run: pnpm exec prisma generate
    # Lint and typecheck run as two SEQUENTIAL steps, never combined into one
    # `turbo lint typecheck` call: running eslint + tsc together across every
    # package in the monorepo is memory-hungry enough to OOM the runner.
    - name: Lint
      run: pnpm exec turbo lint --filter='...[origin/main]'
    - name: Typecheck
      run: pnpm exec turbo typecheck --filter='...[origin/main]'

The --filter='...[origin/main]' flag is Turbo's affected-package filter: on a PR, it only lints and typechecks packages that actually changed (plus anything that depends on them). Touch one file in the marketing app, and the unrelated web app's packages don't get re-checked. The daily cron and manual workflow_dispatch runs drop the filter and validate the entire monorepo, so nothing can silently drift out of scope for good.

Why there's no composite action for the repeated setup

You'll notice checkout → pnpm/action-setup → setup-node → prisma generate repeats near-verbatim across every job. That's not an oversight: every job runs on its own isolated VM in GitHub Actions, so there's no state to share between them beyond what you explicitly cache. A composite action could shrink those four lines to one, but the real cost isn't the YAML boilerplate, it's pnpm install and Turbo actually doing work: that's what the per-job-type .turbo cache (keyed on github.sha, with a restore-keys fallback to the last known cache for that job type) is there to shrink.

Tests: unit, security, and a job the article's stack alone doesn't cover

test:
  runs-on: ubuntu-latest
  steps:
    - run: pnpm exec turbo test --filter='...[origin/main]'
    - uses: actions/upload-artifact@v4
      if: failure()
      with:
        name: test-results
        path: coverage/

Same affected-package filtering as lint and typecheck. On failure, the coverage report gets uploaded as an artifact, so you don't have to reproduce the failure locally just to see what didn't run.

One more job exists specifically because Turbo can't see it: a no-code generator script living outside the workspace (in scripts/, not inside any package) has its own dedicated job, test-nocode, gated behind a path filter so it only runs when the generator, its shared config package, or the Prisma schema actually change. It's isolated on purpose: its fixture typechecks a real, freshly generated SaaS project end to end, which takes close to 10 minutes, far longer than anything else in the pipeline.

E2E: conditional, and stubbed rather than backed by a real database

e2e:
  needs: changes
  if: needs.changes.outputs.marketing == 'true'
  runs-on: ubuntu-latest
  steps:
    - uses: actions/checkout@v4
    - uses: pnpm/action-setup@v5
    - uses: actions/setup-node@v5
      with:
        node-version: "22"
    - run: pnpm install --frozen-lockfile
    - run: pnpm exec prisma generate
    - name: Install Playwright Chromium
      run: pnpm --filter @heartco/marketing exec playwright install --with-deps chromium
    - name: E2E (marketing)
      run: pnpm --filter @heartco/marketing test:e2e
    - uses: actions/upload-artifact@v4
      if: failure()
      with:
        name: playwright-traces
        path: apps/marketing/test-results/

Two things worth noticing. First, it only runs when a changes job (a paths-filter step upstream) detects that the marketing app's scope was actually touched by the PR: e2e is the single most expensive job in the run, so it stays off the critical path for changes that can't possibly affect it. Second, there's no Postgres service and no seed script here: the dev server it tests against runs with SKIP_ENV_VALIDATION=1 and the specs stub the paid checkout tunnel via Playwright's page.route, so the suite needs zero real secrets and zero test database to exercise the flows that actually generate revenue.

Build: excluding what can't build in CI

build:
  needs: [lint-and-typecheck, test]
  env:
    DATABASE_URL: "postgresql://fake:fake@localhost:5432/fake"
    NEXTAUTH_SECRET: "ci-secret-placeholder"
    NEXTAUTH_URL: "http://localhost:3000"
    SKIP_ENV_VALIDATION: "1"
    RESEND_API_KEY: "re_placeholder"
    AUTH_SECRET: "ci-auth-secret-placeholder-32chars!!"
  steps:
    - run: pnpm exec turbo build --filter='...[origin/main]' --filter='!@heartco/video'

Every value here is a placeholder, never a real secret: pnpm build only needs env.js (the @t3-oss/env-nextjs schema) to be satisfied with something of the right shape, not with anything that actually works at runtime. The !@heartco/video exclusion is a real, specific workaround: that package's build step runs a Playwright screenshot capture that breaks under CI's constraints, so it's carved out explicitly rather than let the pipeline fail on something unrelated to the change being reviewed.

What actually gates production

There's no deploy step in this workflow, and no Vercel-side "wait for CI" flag either. The real gate is GitHub's branch protection: main requires the jobs above to be green on a branch that's up to date with main, which is exactly what re-running a stale PR would fail to guarantee. Once a PR merges, Vercel's GitHub App deploys the resulting commit on its own, on its own schedule, with no coordination needed from this file.

The other cost lever lives outside ci.yml entirely: a vercel-ignore.sh "Ignored Build Step" script on each Vercel project skips preview builds for feature branches, so pushing a branch doesn't also trigger two extra Vercel builds (web + marketing) on top of the GitHub Actions run already validating it. Production builds on main are never skipped.

Environment variables and secrets

Three tiers, matching what actually needs which level of trust:

  1. Public, in the workflow file (like NODE_VERSION) → nothing sensitive, versioned in the repo
  2. GitHub Secrets (Settings → Secrets → Actions) → only if you add real integration tests against external services; this pipeline's own build and test jobs run entirely on placeholders, as shown above
  3. Vercel environment variables (Vercel Dashboard) → the actual production secrets, never touched by CI

Golden rule

Never put a production secret in GitHub Secrets your CI/CD workflows don't need. This pipeline's build never touches a real database, a real Stripe key, or a real auth secret — and it still proves the app builds.

Keeping the pipeline fast

What actually keeps this fast on a monorepo with more than one deployable app:

  1. A .turbo cache scoped per job type (lint, test, build each get their own cache key on github.sha, falling back to the last cache for that job type) → unchanged packages are skipped, not just faster
  2. Turbo's affected-package filter (--filter='...[origin/main]') → a PR only re-validates what it could plausibly have broken
  3. Path-based job skipping for the two most expensive jobs (e2e, test-nocode), via a lightweight changes job:
changes:
  runs-on: ubuntu-latest
  outputs:
    marketing: ${{ steps.filter.outputs.marketing }}
    nocode: ${{ steps.filter.outputs.nocode }}
  steps:
    - uses: dorny/paths-filter@v4
      id: filter
      with:
        filters: |
          marketing:
            - "apps/marketing/**"
            - "packages/**"
          nocode:
            - "scripts/**"
            - "packages/shared/**"
            - "prisma/**"
  1. Concurrency cancellation (see the trigger section above) → a superseded run is cancelled outright instead of finishing and wasting the minutes
  2. No push: main trigger → the single biggest saving: nothing runs twice for the same code

Common mistakes

❌ npm install instead of pnpm install --frozen-lockfile → slower, and non-deterministic CI.

❌ Forgetting --frozen-lockfile → CI installs different versions than your machine did. A production bug, guaranteed.

❌ Running tests against a real, shared database in parallel jobs → race conditions between jobs. Isolate with per-job service containers, or avoid a real database in CI altogether where you can.

❌ No timeout-minutes → a hung test can quietly burn hours of CI. Set one on every job.

❌ Re-running the full suite on every push to main → if a PR already validated the exact tree that's merging, that validation doesn't need to happen twice.

Monitoring

If you want to know the moment main goes red on a scheduled or dispatched run (since PR-only triggering means there's no push-to-main event to watch), a Slack step slotted into the workflow is a five-minute addition:

- name: Notify Slack on failure
  if: failure() && github.event_name != 'pull_request'
  uses: slackapi/slack-github-action@v1
  with:
    webhook: ${{ secrets.SLACK_WEBHOOK }}
    payload: |
      {
        "text": "🚨 Scheduled CI run failed on main"
      }

Conclusion

A good CI/CD pipeline isn't complicated science, it's hygiene: don't merge what breaks, deploy what passes, know the moment something you didn't touch quietly stops working. What makes this one interesting isn't length, it's the handful of specific, load-bearing decisions: PR-only triggering with a daily full-monorepo cron as the safety net, Turbo's affected-package filtering so a monorepo doesn't mean paying to re-validate everything on every change, and expensive jobs (E2E, the no-code generator's real-project typecheck) gated behind actual path changes rather than running unconditionally.

This is the real pipeline running in this repository today, and the shape you inherit the moment you clone HeartCo Starter.

Go further

Share

Ready to launch your SaaS?

HeartCo Starter includes everything you need: auth, payments, AI, mobile, audited security. Starting at $219.

30-day money-back guarantee