DEV Community

Gumbo Sveins
Gumbo Sveins

Posted on

Build a Vercel env matrix offline before you merge (and stop shipping the wrong secrets)

Most “env is undefined in production” threads are not mysterious. Someone added the key in the Vercel dashboard, left Preview checked and Production unchecked, or changed a NEXT_PUBLIC_* value and never redeployed. The dashboard looks fine. The merge goes green. The custom domain is wrong for three hours.

I already wrote the full Next.js / Vercel go-live pass and a Polar sandbox vs live webhook deep dive. This piece is narrower: how to prove your env matrix is sane before you merge, on your laptop, without uploading secrets to a third-party scanner.

Official Vercel and Next.js docs win when they disagree with anything here.


What an “env matrix” actually is

Vercel has three deployment worlds. Each secret either belongs in a world or it does not.

Key family Development Preview Production
Public site URL / AUTH_URL http://localhost:3000 Preview origin or staging domain Custom domain
AUTH_SECRET Laptop-only value Separate preview value Separate live value
Billing tokens (Polar, Stripe, …) Sandbox / test Sandbox / test Live org only
Database URL Local or personal branch Disposable branch / staging Main / prod project
Transactional email Test recipients Test recipients Verified domain
NEXT_PUBLIC_* feature flags Whatever you are debugging Safe defaults Live defaults

If you cannot fill every cell for every secret your app reads, you do not have a matrix — you have hope.

The matrix is not a Vercel feature. It is a table you own that answers three questions per key:

  1. Is this key required for a healthy deploy of this world?
  2. Which Vercel scopes should be ticked?
  3. After I change it, do I need a new build (anything NEXT_PUBLIC_*) or is a server restart / new Function deploy enough?

The failure mode a matrix catches that next build will not

next build on your laptop uses .env.local. Vercel Preview uses Preview-scoped dashboard values. Production uses Production-scoped values. Those three sets can diverge forever while CI stays green.

Typical silent split:

Laptop:     POLAR_ACCESS_TOKEN=<sandbox>   POLAR_SERVER=sandbox
Preview:    POLAR_ACCESS_TOKEN=<sandbox>   POLAR_SERVER=sandbox   ✅
Production: POLAR_ACCESS_TOKEN=<sandbox>   POLAR_SERVER=production  ❌
Enter fullscreen mode Exit fullscreen mode

Build: pass. Checkout button: may even render. Entitlements / API calls: confusing 401s. The Polar webhook article covers the delivery side; the matrix catches the token–server mismatch before you post the buy link.

Same class of bug with Neon pooled vs direct, Resend onboarding@resend.dev still in Production, or NEXT_PUBLIC_APP_URL still pointing at *.vercel.app after you attached a domain.


Build the matrix from files you already have

You do not need a SaaS. Start from three artifacts:

  1. Required keys — your committed .env.example (names only, never values).
  2. Filled local file.env.local or a redacted export from vercel env pull (Development only — that CLI pull does not magically give you Production).
  3. Dashboard truth — a manual pass over Vercel → Settings → Environment Variables, or a screenshot you keep private.

Step 1 — Diff required vs present

For every key in .env.example:

  • Present in local filled file?
  • Empty (KEY= with nothing after =)? Flag it. Empty is worse than missing — Next may treat it as “set.”
  • Orphan in local file but not in .env.example? Either document it or delete it. Orphans are how old Stripe keys survive Polar migrations.

A 20-line script is enough if you like shells:

#!/usr/bin/env bash
# compare-env-keys.sh — names only; never prints values
set -euo pipefail
example="${1:-.env.example}"
filled="${2:-.env.local}"

keys() {
  grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$1" | cut -d= -f1 | sort -u
}

comm -23 <(keys "$example") <(keys "$filled") | sed 's/^/MISSING: /'
comm -13 <(keys "$example") <(keys "$filled") | sed 's/^/ORPHAN: /'

while IFS= read -r line; do
  key="${line%%=*}"
  val="${line#*=}"
  if [[ -z "${val}" ]]; then
    echo "EMPTY: $key"
  fi
done < <(grep -E '^[A-Za-z_][A-Za-z0-9_]*=' "$filled")
Enter fullscreen mode Exit fullscreen mode

Run it in CI against a fixture .env that only contains placeholder non-secrets if you want a gate. Do not commit real .env.local.

Step 2 — Assign scopes on paper (or Markdown)

Create docs/env-matrix.md in the repo (values never committed):

| Key | Dev | Preview | Production | Notes |
| --- | --- | --- | --- | --- |
| DATABASE_URL | ✓ local | ✓ branch | ✓ pooled prod | runtime = pooled |
| DATABASE_URL_UNPOOLED | ✓ | ✓ | ✓ | migrations only |
| POLAR_ACCESS_TOKEN | sandbox | sandbox | live | never NEXT_PUBLIC |
| POLAR_SERVER | sandbox | sandbox | production | must match token |
| POLAR_WEBHOOK_SECRET | sandbox ep | sandbox ep | live ep | matched set |
| NEXT_PUBLIC_APP_URL | localhost | preview URL | https://app.example | redeploy after change |
| AUTH_SECRET | laptop | preview-only | prod-only | 32+ bytes, distinct |
| EMAIL_FROM | resend.dev | resend.dev | verified domain | |
Enter fullscreen mode Exit fullscreen mode

Tick columns the way Vercel’s checkboxes work. “Apply to all” is fine for true public config (NEXT_PUBLIC_MARKETING_SITE=https://…). It is wrong for every billing and database secret.

Step 3 — Mark rebuild vs runtime

Prefix / consumer Change requires
NEXT_PUBLIC_* New build (value inlined at next build)
Server-only (POLAR_*, DATABASE_URL, …) New deploy of server / Functions; no client bundle bake
Edge middleware reading env Confirm it sees the same scope; redeploy middleware

If you edit NEXT_PUBLIC_APP_URL in the dashboard and skip redeploy, production keeps the old origin. Auth loops and “wrong redirect URI” reports follow.


A PR checklist that takes five minutes

Paste this into the PR template. Require a human checkbox before merge to main:

### Env matrix (required for main)

- [ ] `.env.example` lists every key this PR newly reads
- [ ] No new secret uses the `NEXT_PUBLIC_` prefix
- [ ] Preview-scoped values are sandbox / branch / test-mail only
- [ ] Production-scoped values are prepared in the dashboard **before** merge (or this PR is docs-only)
- [ ] If any `NEXT_PUBLIC_*` changed: redeploy plan noted in the PR body
- [ ] Polar / billing: token origin matches `POLAR_SERVER` (see webhook article)
- [ ] Webhook URL for live org is the custom domain, not a preview hostname
Enter fullscreen mode Exit fullscreen mode

This does not replace a go-live smoke test. It stops the most common self-own: merging feature work that assumes Production secrets that were never created.


Offline tool vs checklist (when each wins)

A Markdown checklist (like the launch kit article) teaches what to set. An offline matrix tool earns its keep when you are staring at your messy .env.local and need a missing-key report plus a filled Production / Preview / Development table without uploading secrets.

I built a small browser-only helper for that job — paste .env.example + filled env, get a report and a scope matrix export. Parsing stays on your machine:

Deploy Guard — Offline Env Matrix for Vercel ($12):

https://buy.polar.sh/polar_cl_fXjUOUonsouTBvvQLXBVvd1VdA2hVaw6LeBi32fuIjc

If you want the longer ~60-point go-live pass plus five annotated .env.example packs (Next/Vercel, Polar, Neon, Resend, Supabase), that is the companion download:

Next.js / Vercel Production Launch Kit ($19):

https://buy.polar.sh/polar_cl_uxzixqNM81nkPoIW3um9aacYzAmA1bXQbWqUe3EQE75

Neither is required to use the bash diff or the Markdown matrix above. Buy only if you want the packaged version; the free method is enough for a solo app with under ~40 keys.


Wire a cheap CI gate (names only)

GitHub Actions can fail a PR when .env.example drifts from code without ever seeing secrets:

# .github/workflows/env-example.yml
name: env-example
on:
  pull_request:
    paths: ['.env.example', '**/*.{ts,tsx,js,jsx,mjs}']

jobs:
  keys-mentioned:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Fail if example is empty
        run: |
          test -s .env.example
          grep -qE '^[A-Za-z_][A-Za-z0-9_]*=' .env.example
Enter fullscreen mode Exit fullscreen mode

Stronger gates (AST-scan for process.env.FOO vs example) are worth it once the team is >1. For a solo indie app, the PR checklist + offline diff before you touch Production scopes is the higher ROI habit.

Never run vercel env pull --environment=production in CI and commit the result. Production values stay in the dashboard (Sensitive) or a secrets manager — not in Actions logs.


Evening-of merge sequence (env only)

When the PR is ready and you are about to ship:

  1. Open docs/env-matrix.md (or Deploy Guard export). Confirm every Production cell has a dashboard row with Production ticked and Preview unticked for live secrets.
  2. Confirm Preview still has sandbox billing + branch DB + test email.
  3. Change any NEXT_PUBLIC_* Production values before the merge, then expect the production deploy to rebuild.
  4. Merge → wait for Production deploy → private window smoke: home, auth, one write, one billing path if you have it.
  5. If Polar is in the mix: live webhook delivery page must show a recent 2xx (details in the webhook article).

Rollback is Instant Rollback on Vercel plus knowing whether you migrated the database forward. Env mistakes rarely need a DB rollback; wrong migrations do.


Quick reference — symptoms → matrix cell

Symptom Matrix cell to inspect first
Runtime undefined for a server secret Production scope missing; or only set on Preview
Public config wrong after dashboard edit NEXT_PUBLIC_* without redeploy
Works on PR URL, fails on custom domain NEXT_PUBLIC_APP_URL / AUTH_URL still preview origin
Billing 401 after go-live Token world ≠ POLAR_SERVER / SDK server
Checkout paid, app still free-tier Webhook URL / secret world (not an env-matrix-only bug — see webhook post)
too many connections on Neon Runtime using unpooled URL in Production

Ship rule

Merge when Preview was green on non-production secrets, the Production column of your matrix is filled with live-only values, and you know whether the next deploy must rebuild public env. Post the URL after the private-window smoke — not after the GitHub check turns green alone.

Educational material only. Re-read Vercel’s environment variables docs and the Next.js production checklist before you ship. Vendor names belong to their owners.

Top comments (0)