Works Locally, Fails in Production: The Fix Checklist

Works on localhost, breaks on Vercel / Render / Cloudflare Pages. Reproduce the prod build locally, diff env vars, pin the Node version, and fix Linux case-sensitivity — the systematic checklist.

The classic pain: npm run dev is perfect locally. Deploy to Vercel / Netlify / Cloudflare Pages / Render and the build fails, the runtime crashes, or the page loads but every feature is broken. “Works on my machine” is the oldest joke in software, and the root cause is almost always the same family: implicit local assumptions don’t hold in production.

Fastest fix (start here): run the production build locally with npm run build && npm start (or npm run preview for Vite). Roughly 90% of “works locally, fails in prod” reproduces on your own machine in under a minute — which means it’s a build or code bug, not a deployment mystery. If it builds and runs clean locally but still fails deployed, the difference is environment: env vars, Node version, or Linux case-sensitivity, in that order of likelihood.

This is a diffing job, not a guessing job. Work the five axes below: env vars → runtime version → filesystem → network/CORS → build process.

First, find your bucket

Match the production error string to the most likely cause before you start editing anything.

Symptom in prod logs / consoleMost likely causeJump to
Cannot read properties of undefined, Invalid URL, DB connection refusedEnv var missing in prodStep 2
X is not a function, SyntaxError: Unexpected token, Unsupported engineNode/Python version mismatchStep 3
Module not found / Cannot find module './Button' (builds fine locally)Linux case-sensitivityStep 4
EROFS: read-only file system, ENOENT on a path you wrote at runtimeLocal-only filesystem/stateStep 5
Works in dev, breaks only after build (blank page, hydration error)Dev server vs prod buildStep 6
CORS error, 401/403, cookie not set, OAuth redirect mismatchOrigin/cookie/key differencesStep 7
Timestamps off by N hoursTimezone/localeStep 8

Common causes, by hit rate

1. Env vars not set in production

The single most common cause. Your local .env has DATABASE_URL, STRIPE_SECRET_KEY, etc., but the deploy platform never received them, so process.env.X is undefined at runtime. A second, sneakier variant: the var is set, but only for the Preview environment and not Production (Vercel scopes vars per environment), or a browser-exposed var is missing the required prefix (NEXT_PUBLIC_, VITE_, PUBLIC_) so the client bundle ships without it.

How to spot it: prod logs show Cannot read properties of undefined, Invalid URL, or a database/Redis connection refusal at boot.

2. Node / Python version mismatch

Local Node 24, prod on an older major — or the reverse. As of June 2026 the platform defaults have moved up, so the old “prod is stuck on Node 18” assumption is usually wrong now, but a pinned old version in a .nvmrc or project setting still bites:

  • Vercel: offers 24.x (default), 22.x, 20.x. Node 18.x is deprecated.
  • Cloudflare Pages: the V3 build image defaults to Node 22 (it replaced the old Node 18 default in 2025).
  • Render: new services default to Node 22.10.0; existing services keep whatever they were created with.
  • Netlify: honors .nvmrc / NODE_VERSION; current default tracks an active LTS.

ES2023+ syntax and APIs (Array.prototype.toSorted, findLast, top-level await in certain configs) exist on Node 20+ but not 18, so a service still pinned to 18 will crash on modern code.

How to spot it: prod logs show X is not a function, SyntaxError: Unexpected token, or the platform refuses the build with Unsupported engine / EBADENGINE.

3. Filesystem differences (case sensitivity, path separators)

Your laptop lies to you. macOS (APFS, default) and Windows (NTFS) are case-insensitive; Linux — which every major host runs in production — is case-sensitive.

// macOS / Windows: case-insensitive, this resolves
import './components/Button'; // file on disk is button.tsx — still works locally

// Linux prod: case-sensitive, this throws
import './components/Button'; // Error: Cannot find module './components/Button'

The same trap hits asset paths (/Logo.png vs /logo.png) and any string path you read at runtime.

How to spot it: prod build errors with Module not found / Cannot find module, but npm run build is clean on your Mac/Windows machine.

4. Local-only services (filesystem, in-memory state)

Locally you do fs.writeFile('./uploads/x.jpg') or keep a cache in a module-level variable. On serverless platforms (Vercel Functions, Cloudflare Workers/Pages Functions) the filesystem is read-only except for /tmp, and each invocation may hit a different cold instance — so in-memory state silently resets and writes throw.

How to spot it: EROFS: read-only file system, ENOENT, or “the upload worked once then 404s,” or a cache/session that randomly empties.

5. Dev server vs prod build behave differently

Vite / Next / Webpack dev mode runs unbundled with HMR and loose parsing. The prod build tree-shakes, minifies, and (for Next/Astro) server-renders. Code that leans on dev-only behavior — reading import side effects, window during SSR, non-deterministic component output — breaks only after build.

How to spot it: npm run dev is fine, but npm run build && npm start reproduces a blank page, a React hydration mismatch, or a window is not defined SSR error.

localhost:3000 and yourdomain.com are different origins, so they hit different CORS allowlists, different cookie SameSite/Secure behavior (Secure cookies are dropped on plain http://localhost unless you opt in), and different OAuth redirect-URI allowlists. A frequent miss: dev used the provider’s test keys and the redirect URI registered in the OAuth app only lists localhost.

How to spot it: prod Network tab shows a CORS error, a 401/403, “cookie not set,” or an OAuth redirect_uri_mismatch that never appeared in dev.

7. Timezone / locale

new Date().toString() renders in your laptop’s local zone; the prod server runs in UTC. Every server-formatted timestamp is then off by your UTC offset.

How to spot it: dates/times are consistently shifted by a fixed number of hours in prod.

Shortest path to fix

Step 1: Reproduce the prod build locally

Run the actual production build before you touch the deploy platform:

# Next.js
npm run build && npm start

# Vite (React/Vue/Svelte SPA)
npm run build && npm run preview

# Astro / generic Node server
npm run build && NODE_ENV=production node ./dist/server/entry.mjs

If it fails here, it’s a build/code issue, not deployment — fix it locally where the feedback loop is fast. If it’s clean, the gap is environmental; continue below.

Step 2: Diff env vars (the reliable way)

Don’t hand-transcribe the dashboard. On Vercel, pull the deployed env into a local file and diff it against your working .env:

# Pull production env from Vercel into .env.production.local
vercel env pull .env.production.local --environment=production

# Normalize both to KEY names and compare what exists where
comm -3 \
  <(grep -oE '^[A-Z0-9_]+' .env | sort -u) \
  <(grep -oE '^[A-Z0-9_]+' .env.production.local | sort -u)

Anything that prints on the left exists locally but is missing in prod. Add the missing ones:

vercel env add DATABASE_URL production
vercel env add STRIPE_SECRET_KEY production
# CLI prompts you to paste the value (it is not echoed)

Then redeploy — env var changes do not apply to existing deployments. Checklist for the common gotchas:

  • Set for the right environment (Production, not just Preview/Development).
  • Browser-exposed vars carry the framework prefix: NEXT_PUBLIC_, VITE_, PUBLIC_ (Astro/SvelteKit).
  • Multiline secrets (private keys) keep their newlines — paste, don’t retype.

On other platforms, validate from the dashboard: Render → service → Environment; Cloudflare Pages → Settings → Variables and Secrets; Netlify → Site configuration → Environment variables.

Step 3: Pin the runtime version

Make local, CI, and prod agree on one Node major. Pin it in package.json and a version file:

// package.json — Vercel/Netlify/Cloudflare read engines.node
{
  "engines": {
    "node": "22.x"
  }
}
// .nvmrc (also accepted: .node-version)
22

Platform specifics as of June 2026:

  • Vercel deploys the latest patch of the major you request (e.g. 22.x → newest 22). engines.node overrides the dashboard setting. Verify with node -v in the Build Command, or log process.version at runtime.
  • Cloudflare Pages resolves in this order: .nvmrc / .node-versionNODE_VERSION build env var → default (Node 22 on the V3 image).
  • Render reads NODE_VERSION (env var) or .node-version; accepts a semver or alias like lts.

Step 4: Fix Linux case sensitivity

Find imports whose casing doesn’t match the file on disk:

# List relative imports, then eyeball against actual filenames
grep -rEn "from '\.\./|from '\./" src/ | sort -u | head -50
ls -la src/components/

To catch it automatically and reliably, run the build in a Linux container that matches prod (a one-line dry run, no APFS volume surgery needed):

docker run --rm -v "$PWD":/app -w /app node:22-slim sh -c "npm ci && npm run build"

If that build fails with Cannot find module while your local build passes, you’ve found a casing bug. Lint it permanently with eslint-plugin-import (import/no-unresolved) and rename files/imports to match exactly.

Step 5: Replace local-only dependencies

Swap anything that assumes a writable, persistent local box for a cloud-native equivalent:

Local (works on your machine)Production replacement
fs.writeFile to ./uploadsS3 / Cloudflare R2 / Cloudinary
in-memory cache / global varRedis (Upstash) / Cloudflare KV
local SQLite filePostgres (Neon/Supabase) / Turso
local cron / setIntervalVercel Cron / Cloudflare Cron Triggers / Inngest
secrets read from a local fileplatform env vars / a secrets manager

On serverless, if you must touch disk, only /tmp is writable and it is ephemeral per invocation.

Step 6: Make dev and prod behavior match

Don’t fork logic on NODE_ENV — it guarantees dev can’t catch prod bugs:

// Anti-pattern: dev never exercises the prod path
if (process.env.NODE_ENV === 'development') {
  // ...
}

// Better: an explicit, testable flag you can flip in staging
const useMock = process.env.USE_MOCK === 'true';

Then run a staging/preview deploy with the production flag set and validate there before merging. For SSR frameworks, guard browser-only globals: if (typeof window !== 'undefined') before touching window/document.

Step 7: Reconcile CORS, cookies, and OAuth for the prod origin

Your prod origin (https://yourdomain.com) is not the dev origin (http://localhost:3000), so anything keyed to origin has to be re-registered:

  • CORS allowlist: add the prod origin to the server’s Access-Control-Allow-Origin (and any preflight config), not just localhost. If you send cookies cross-origin, set Access-Control-Allow-Credentials: true and use an explicit origin, not *.
  • Cookies: prod is HTTPS, so set Secure and pick a SameSite that fits — SameSite=None; Secure for cross-site, Lax for same-site. A cookie that worked on http://localhost can silently drop in prod if Secure is required but the request isn’t HTTPS, or if the Domain is wrong.
  • OAuth: add the prod callback (https://yourdomain.com/api/auth/callback/...) to the provider’s redirect-URI allowlist, and switch from the provider’s test keys to live keys.

Verify: load the prod site, reproduce the auth/fetch flow, and confirm the Network tab shows no CORS block, no redirect_uri_mismatch, and the session cookie is set with the expected Secure/SameSite attributes.

Step 8: Standardize on UTC

// Server: store and transport as UTC ISO strings
const iso = new Date().toISOString(); // e.g. 2026-06-21T08:00:00.000Z

// Client: format to the user's zone at display time only
new Intl.DateTimeFormat('en-US', { timeZone: 'America/New_York' }).format(new Date(iso));

Set TZ=UTC on the server too, so any stray local-time formatting is at least consistent.

How to confirm it’s fixed

  1. The local prod build (npm run build && npm start) passes clean.
  2. vercel env pull diff shows zero keys missing from production.
  3. node -v in the build log (or process.version at runtime) matches your pinned major on every environment.
  4. The Linux container build (docker run … node:22-slim … npm run build) succeeds.
  5. Redeploy, then re-trigger the exact action that failed. The original error string is gone from the production logs and the browser console is clean.

FAQ

Why does it build locally but fail only on the deploy platform? Your dev machine is case-insensitive (macOS/Windows) while the deploy runs on case-sensitive Linux, and it has env vars your prod project doesn’t. Reproduce both with a node:22-slim Docker build plus a vercel env pull diff and the gap usually shows up immediately.

npm run dev works but npm run build fails — what’s different? The dev server runs unbundled with loose parsing; the prod build tree-shakes, minifies, and (for SSR frameworks) server-renders. Common culprits: touching window/document during SSR, React hydration mismatches, and import side effects that only the bundler drops. Reproduce with npm run build && npm start locally.

I added the env var on Vercel but it’s still undefined. Env var changes only apply to new deployments, so trigger a redeploy. Then confirm it’s set for Production (not only Preview), and that any client-side var uses the framework prefix (NEXT_PUBLIC_ / VITE_ / PUBLIC_) — without it the value never reaches the browser bundle.

How do I pin the same Node version everywhere? Put "engines": { "node": "22.x" } in package.json and 22 in .nvmrc. Vercel, Netlify, and Cloudflare Pages all read these. In GitHub Actions use actions/setup-node@v4 with node-version-file: .nvmrc. Verify with node -v in each build log.

My timestamps are off by exactly N hours in production. The prod server runs in UTC and your laptop doesn’t. Store and transport new Date().toISOString(), set TZ=UTC on the server, and convert to the user’s timezone only at display with Intl.DateTimeFormat({ timeZone }).

Prevention

  • Run npm run build && npm start before every PR — never trust the dev server alone.
  • Validate env vars with zod (or similar) at boot so a missing key fails fast with a clear message instead of crashing deep in a request.
  • Pin engines.node in package.json and ship a matching .nvmrc; have CI use actions/setup-node@v4 with node-version-file: .nvmrc.
  • Mac users: run the build inside a Linux container (node:22-slim) to surface case-sensitivity bugs before deploy.
  • Replace any local-only code (fs.write, in-memory state) with a cloud equivalent on day 1.
  • Keep a preview/staging environment whose config mirrors prod, and validate every PR there before merge.
  • Store all times as UTC ISO strings; convert to a timezone only at display.
  • Put the whole team on one .nvmrc / .python-version to retire “works on my machine.”

Tags: #Backend #Debug #Troubleshooting