It works locally but not in production. Here’s the checklist.

Nine differences between your machine and production, in the order they break: env vars, build output, ports, database, CORS, paths, time, memory and secrets.

By UmaizPublished 9 min read

“It works locally” is almost never a code problem. It’s an environment problem — something your machine provides that the server doesn’t, or provides differently. The fix is to find the specific difference rather than to keep redeploying, and there are about nine differences that account for nearly all of these failures.

Work through them in this order. It’s roughly the order of how often each one is the culprit.

1. Environment variables

By far the most common cause. Your .env.local is on your machine and — correctly — not in your repository, so the server has never seen it.

Check on the server, not in your head:

# On the server or in your host’s dashboard, print the names only.
printenv | cut -d= -f1 | sort

Three things go wrong here beyond simply forgetting one:

  • Build-time vs run-time. Variables baked into a frontend bundle are read when you build, not when you run. Setting one after the build changes nothing until you rebuild.
  • Client-side prefixes. Frameworks only expose variables to browser code when they carry a specific prefix — NEXT_PUBLIC_, VITE_, REACT_APP_. Without it the value is undefined in the browser and nowhere near an error message.
  • Quotes and trailing whitespace. KEY="value " will authenticate against nothing and report only a 401.

2. You’re running a different build

Locally you run a dev server. In production you run a compiled bundle. These behave differently on purpose:

  • Dev servers are permissive about import casing; production builds are not (see §6).
  • NODE_ENV=production disables development warnings and enables minification, which changes error text and can change behaviour that depended on those warnings.
  • Dev servers often proxy API requests for you. Production doesn’t, which is why local relative URLs stop resolving.

Run the production build locally before blaming the server:

npm run build && npm start

If it fails here, you’ve saved yourself a deploy cycle. If it succeeds here and fails there, the difference is genuinely environmental — keep going down the list.

3. Ports and binding

A server bound to 127.0.0.1 accepts connections only from inside the machine. Behind a reverse proxy or in a container, nothing outside can reach it. Bind to 0.0.0.0 instead, and use the port the platform gives you:

const port = process.env.PORT || 3000
app.listen(port, '0.0.0.0')

Hard-coding 3000 when the platform assigned something else produces a service that starts successfully and answers nobody.

4. The database is not the same database

Local Postgres is often trusting in ways production isn’t:

  • Production usually requires SSL. Locally it usually isn’t configured, so the connection string that works on your machine is rejected with a vague handshake error.
  • Migrations you ran locally have not been run in production. The schema differs, and the error you get names a missing column rather than a missing migration.
  • Connection limits are far lower on managed instances. A pool sized for your laptop exhausts them and fails intermittently under load — which looks like a random bug, not a configuration one.

5. CORS and cookies

Locally everything is localhost, so the browser treats it as one origin. In production your frontend and API are usually on different hostnames, and the browser starts enforcing rules it was previously ignoring.

Two separate things fail here, and people usually fix only the first:

// The API must allow the browser origin AND credentials.
app.use(cors({ origin: 'https://yourapp.com', credentials: true }))
// And a cross-site cookie must say so explicitly.
res.cookie('session', token, {
  httpOnly: true,
  secure: true,      // required whenever sameSite is 'none'
  sameSite: 'none',  // required for cross-origin requests
})

If login “works” but the user is logged out on the next request, this is nearly always the cause.

6. File paths and casing

macOS and Windows filesystems are case-insensitive by default. Linux — which your server almost certainly runs — is not.

import Button from './components/button'  // the file is Button.tsx

That resolves on your machine and fails on the server with a module-not-found error. It is the single most common “but I changed nothing” deployment failure.

Relative paths break the same way: they resolve against the working directory the process was started in, which is rarely the directory you assumed.

7. Time and locale

Your machine is in your timezone. The server is almost certainly UTC. Anything that formats a date, compares timestamps or computes “today” will produce different answers in the two places — and the bug appears only for users near a date boundary.

Store timestamps in UTC, format them at the point of display, and test with TZ=UTC set locally.

8. Memory and cold starts

Your laptop has more memory than a small VPS or a serverless function. Processes that work locally get killed in production, which surfaces as an unexplained restart or a 502 rather than an error you can read.

Check whether your host reports OOM kills before assuming the code is at fault.

9. Secrets that were never real

Prototypes accumulate placeholder credentials — a test Stripe key, a sandbox mail token, a database URL pointing at a seeded local copy. Each of these works right up until a real user touches it.

The order to check them

  1. Are all environment variables present on the server, with the right names and no quotes?
  2. Does npm run build && npm start succeed locally?
  3. Is the server binding to 0.0.0.0 and to process.env.PORT?
  4. Does the production database have SSL enabled and all migrations applied?
  5. Do CORS origin, credentials, and cookie secure/sameSite all agree?
  6. Do all import paths match file casing exactly?
  7. Are timestamps stored in UTC?
  8. Is the process being killed for memory?
  9. Are any credentials still test or placeholder values?

Read the actual logs

Almost every deployment failure is legible in the server logs, and almost nobody looks at them before redeploying. Find them in your host’s dashboard — the exception message is usually specific enough to end the search in one read.

If the logs are empty, that is itself the finding: your app isn’t starting, or its output isn’t being captured. Both are more tractable problems than “it doesn’t work”.

← All insights

Show me what’s stuck.

Don’t spend another six hours fighting the same bug. Send me what you’ve got — the messy version is usually the useful version.

Both open with a short template already filled in.