deployhealth

How to catch a missing env var in Next.js before it reaches production

A Next.js app that reads process.env.STRIPE_SECRET_KEY builds and deploys without it, then fails on the first request that needs it. A NEXT_PUBLIC_ variable is worse: Next.js inlines it at build time, so a missing one ships as undefined in the browser.

deployhealth compares the variables your code reads with the ones your env files declare, on every pull request and every push, and names each gap with its file and line. It reads names and line numbers only, never values.

What deployhealth reads in a Next.js app

In .ts, .tsx, .js, .jsx, .mjs, .cjs, .mts and .cts files, on one line each:

  • process.env.NAME and process.env?.NAME
  • process.env["NAME"], with any quotes
  • import.meta.env.NAME
  • destructuring: const { A, B: alias, C = "x" } = process.env
  • client components’ NEXT_PUBLIC_ reads, like any other

A route handler, a client component and a database module, with a .env.example that declares three variables:

.env.example
DATABASE_URL=
STRIPE_SECRET_KEY=
NEXT_PUBLIC_POSTHOG_KEY=
app/api/checkout/route.ts
import Stripe from 'stripe';

const stripe = new Stripe(process.env.STRIPE_SECRET_KEY!);
const webhookSecret = process.env['STRIPE_WEBHOOK_SECRET'];
app/providers.tsx
'use client';

export const posthogKey = process.env.NEXT_PUBLIC_POSTHOG_KEY;
lib/db.ts
export const { DATABASE_URL, DB_POOL_SIZE = '10' } = process.env;

What deployhealth reports

  • STRIPE_SECRET_KEY app/api/checkout/route.ts:3 · required
  • STRIPE_WEBHOOK_SECRET app/api/checkout/route.ts:4 · required
  • NEXT_PUBLIC_POSTHOG_KEY app/providers.tsx:3 · required
  • DATABASE_URL lib/db.ts:1 · required
  • DB_POOL_SIZE lib/db.ts:1 · optional (has a default)

MISSING STRIPE_WEBHOOK_SECRET

In a monorepo, each directory with its own env files (say apps/web) is checked against its own .env.example.

Defaults and OPTIONAL

A read with a fallback on the same line, ?? "x" or || "x" (or a default in destructuring), can’t be MISSING: when no env file declares it, the scan lists it as OPTIONAL instead (--show-optional prints each one). A fallback of undefined or null isn’t a default.

Three fallbacks, one of which isn’t:

.env.example
NEXT_PUBLIC_SITE_URL=
lib/config.ts
export const config = {
  siteUrl: process.env.NEXT_PUBLIC_SITE_URL,
  region: process.env.AWS_REGION ?? 'us-east-1',
  logLevel: process.env.LOG_LEVEL || 'info',
  sentryDsn: process.env.SENTRY_DSN ?? undefined,
};

What deployhealth reports

  • NEXT_PUBLIC_SITE_URL lib/config.ts:2 · required
  • AWS_REGION lib/config.ts:3 · optional (has a default)
  • LOG_LEVEL lib/config.ts:4 · optional (has a default)
  • SENTRY_DSN lib/config.ts:5 · required

MISSING SENTRY_DSN

What it skips

  • build output and vendored code: node_modules/, .next/, out/, dist/, .vercel/, .turbo/, coverage/ and the like, minified *.min.js files, and any source file over 512 KB
  • tests and test tooling: *.test.*, *.spec.*, __tests__/, e2e/, playwright/, cypress/, __mocks__/ and runner configs (--include-tests reads them)
  • names the platform provides: NODE_ENV, NEXT_RUNTIME, CI, VERCEL_*, NEXT_PUBLIC_VERCEL_* and GitHub Actions’ own variables (--no-default-ignore checks them too)

Only the read in lib/ counts here:

.env.example
DATABASE_URL=
.next/server/chunks/app.js
const id = process.env.BUILD_CACHE_ID;
app/page.test.tsx
const token = process.env.TEST_ONLY_TOKEN;
lib/db.ts
export const url = process.env.DATABASE_URL;
middleware.ts
export const isProd = process.env.NODE_ENV === 'production';
export const preview = process.env.VERCEL_URL;

What deployhealth reports

  • DATABASE_URL lib/db.ts:1 · required

The CLI still reads test files to see which declared variables they use, so a variable only tests read isn’t reported UNUSED.

Known limits

  • A read split across lines, or with a computed name (process.env[key]), isn’t seen.
  • A variable set only in your hosting platform’s settings is MISSING until an env file declares it: list it in .env.example, with an empty value.
  • MISMATCH compares .env with .env.example only; .env.local and .env.production count as declaring a variable, but aren’t compared.

A computed name reads nothing:

lib/flags.ts
export const flag = (key: string) => process.env[key];

What deployhealth reports

No reads.

Set it up

Pull request checks

Sign in with GitHub, add a project for the repository (owner/repo) and install the deployhealth GitHub App from the project's settings. Every pull request that reads an env var no .env.example declares gets a comment and a check. No token, no secret, no workflow.

Deploy history and alerts

Add the project's ingest token as the repository secret DEPLOYHEALTH_TOKEN (a secret, not a variable) and scan every push. The project's settings page gives you a workflow that runs the CLI pinned to an exact version, npx --yes [email protected], which you can also run from any other CI.

Or use the deployhealth env check Action:

.github/workflows/deployhealth.yml
# .github/workflows/deployhealth.yml
name: deployhealth
on:
  push:
    branches: [main]
jobs:
  env-scan:
    runs-on: ubuntu-latest
    permissions:
      contents: read
    steps:
      - uses: actions/checkout@v5
        with:
          persist-credentials: false
      - uses: Utsav-mishra-25/deployhealth-action@v1
        with:
          token: ${{ secrets.DEPLOYHEALTH_TOKEN }}

@v1 follows the latest 1.x release and can move; pin a full commit SHA if you want it fixed. An npm version never changes once published.

Try it now, without signing up

From your repository's root. It prints MISSING, UNUSED and MISMATCH with file and line in your terminal, and sends nothing:

terminal
npx -y deployhealth-scan --dry-run

Other stacks