You push one line, a copy change on the pricing page, and by the time you refresh the production URL the signup form throws a blank error and the dashboard won’t get past its loading spinner. Back on your machine, on the exact branch you just merged, none of that happens. Signup works, the dashboard loads, and nothing about the code differs between the two places. Something about where it’s running does.

That split, an app broken in production that works fine in dev, traces to a short list of causes. Here they are, before the walk-through.

The five-gap answer, before the details

  1. 01 An environment variable that’s set on your machine and nowhere else
  2. 02 A URL, path, or port hardcoded to localhost
  3. 03 A migration that ran in dev and never ran against the production database
  4. 04 A production build that behaves differently than the dev server you tested on
  5. 05 A dependency that resolved to a different version, or refused to install at all, on the server

Almost every “works on localhost but not on server” report traces to one of those five, alone or stacked, plus the ten minutes of reading logs that tells you which.

Why it works on localhost but not on the server

None of these five gaps means you built the app wrong. They ship so often because the demo is the only grade a coding agent is ever optimizing against. Every test it runs while you build happens on your machine: the .env file already filled in, a database it already has a live connection to, an API on whatever port you left open. Nothing in that loop asks whether the same code, on a fresh server with its own variables, a clean install, and its own build step, behaves the same way. And nothing checks before the push goes live either: at least 17 of the 21 third-party apps in the AxonBuild audit corpus, audited in June and July 2026, had no deploy gate at all, nothing running between “looks fine on my machine” and a stranger loading the URL. I’ve written more about what an unchecked push costs once it’s live in one database, no staging; this post is about the earlier, narrower version of the same gap.

The environment variable that only exists on your laptop

One app in the corpus signs its login cookies with a secret pulled from the environment, correct design on paper. I checked what happens when that variable is unset, expecting a crash. It doesn’t crash, and that’s the part that sat with me: the code falls back to a public phrase hardcoded into the source and signs cookies with that instead, so the app looks exactly as secure as it did a minute before. On a laptop the fallback never fires, because the real secret has sat in the local .env file since setup. Deploy the same code to a host where nobody copied that one variable over, and the fallback is what actually signs every session, readable by anyone who can see the code.

That’s the general shape: a new variable gets added once, locally, into a .env file that already has a dozen others. Adding it to the hosting dashboard is a separate, easy-to-forget step with no compiler to catch the miss. The fix is a startup check that reads every required variable and refuses to boot if one is missing, instead of silently substituting something worse. Some of the gap belongs to the host and some of it never did, which is what your host does and does not cover.

A fetch call still pointed at localhost

The most literal version of this gap is exactly what it sounds like: a request to your own API, or a redirect URL after login, hardcoded to the address it happened to run at while you were building.

// Works from your machine, where the API happens to run on :3001.
// Nothing is listening on :3001 anywhere in production.
const res = await fetch('http://localhost:3001/api/checkout', {
  method: 'POST',
  body: JSON.stringify(cart),
});

Locally it works because your machine is both the browser and the server, so localhost resolves to a port you already have open. Put the frontend and the API on separate hosts, the default for most deploy targets, and that address resolves to nothing. The fix is one environment variable read at request time instead of a string typed once and never revisited: fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/checkout`, ...). What makes this one sneaky is how late it surfaces: invisible in development, invisible in the demo you show a friend, and only visible the first time someone else’s browser tries to reach it.

The migration that ran in dev and never ran in production

A coding agent iterating on a database schema runs its migration against whatever database is open in the editor: your local one, or a dev branch. The new column gets added there, the code that reads it gets written and tested, and it all works, in the one place the migration actually ran. Applying it in production is a separate command (migrate deploy, db:migrate, whatever your tool calls it) that nobody wired into the deploy step, so the live app queries a column production doesn’t have yet.

Unlike a missing import, a forgotten migration doesn’t fail at boot. The app starts and answers most requests fine. Only the query touching the new column throws, usually the first time a real user reaches the feature it was for.

Production build, dev assumptions

Most local testing happens against a dev server, not the build a deploy ships, and the two aren’t the same program. A production build strips or changes things the dev server leaves alone: fuller error messages, debug-only paths, logic gated behind a NODE_ENV check that evaluates one way in each place. The twelve-factor app calls for strict separation of build, release, and run: one artifact, paired with each environment’s own config. Most vibe-coded stacks skip that separation. They have npm run dev, tested constantly, and npm run build, run for the first time by the deploy itself.

The dependency that installed differently on the server

The last gap looks the least like your fault. node_modules on your machine was installed once, months ago, and every install since then mostly reused what was already there. The server does a clean install from scratch on every deploy, and if what it resolves doesn’t match what you’ve been running, or a native module needs a rebuild for an operating system your laptop never tested, the server fails during install, before your code runs at all. npm ci closes most of this gap: unlike npm install, it requires a committed lockfile and installs exactly the versions in it, refusing to proceed if the lockfile and package.json disagree instead of updating around the mismatch. What it doesn’t close: a native module that needs a platform-specific binary can still fail a clean install in a way I haven’t found a one-line fix for. Docker’s whole promise is “installed the same everywhere,” and this is the one corner where that promise still leaks.

Looks fine locally
Different in production
The .env file has every key filled in
The hosting dashboard has whichever keys someone remembered to add
localhost:3001 answers every API call
localhost resolves to nothing off your own machine
The dev database already has the new column
The production database stays on the old schema until someone runs the migration there
npm run dev serves the app you keep testing
npm run build produces a different bundle you may never have run
node_modules was installed once and left alone
A clean install can resolve different versions, or fail on a native module entirely
Looks fine locally
The .env file has every key filled in
localhost:3001 answers every API call
The dev database already has the new column
npm run dev serves the app you keep testing
node_modules was installed once and left alone
Different in production
The .env file has every key filled in
The hosting dashboard has whichever keys someone remembered to add
localhost:3001 answers every API call
localhost resolves to nothing off your own machine
The dev database already has the new column
The production database stays on the old schema until someone runs the migration there
npm run dev serves the app you keep testing
npm run build produces a different bundle you may never have run
node_modules was installed once and left alone
A clean install can resolve different versions, or fail on a native module entirely

Locally, your app has never once run without you standing next to it. Production is the first time it runs alone.

Read the logs before you read the code

When production breaks and localhost doesn’t, the fastest path is reading, not guessing.

  • Read the server log for the failing request, not just the browser console. The error and the line it came from usually name the gap directly: undefined is not an object, ECONNREFUSED 127.0.0.1:3001, relation "orders" does not exist.
  • Confirm the environment variable is actually set on the host, printed once at boot, not assumed from .env.example.
  • Grep the deployed source or built bundle for the literal string localhost.
  • Check whether the migration behind the failing query has run against production specifically, not just local or dev.
  • Run the production build locally and compare it to the dev server you’ve been testing on.
  • Re-run the install clean (npm ci) and read the first error, if there is one.

If the app doesn’t error at all and just does the wrong thing silently, that’s a related but different failure: when your app fails silently and says 200 OK covers what to do when nothing in the log says anything happened.

Common questions about apps that work locally but not in production

Why does my app break in production but not on localhost?

Almost always one of five things: an environment variable that’s missing or different on the server, a hardcoded localhost address, a migration that ran in dev but never ran against the production database, a production build behaving differently than the dev server you tested on, or a dependency that installed differently during a clean install. Read the server log first; the exact error usually points at which one.

Is every difference between dev and production a bug?

No. A production build hiding stack traces from end users is intentional and correct. The problem is the accidental kind: a value nobody set, a command nobody ran, an install nobody repeated. The check is whether the difference was a decision or an oversight.

How do I debug an app that’s broken in production but works in dev?

Start with the server log for the failing request, not the browser console and not the code. The production error message usually names the gap outright: ECONNREFUSED 127.0.0.1 means a hardcoded localhost address, relation does not exist means a migration never ran there, undefined in a config value means an environment variable nobody set on the host. Ten minutes of reading beats an afternoon of guessing.

The pre-deploy check you can run yourself

Same five gaps, run before you push instead of after a customer finds one:

  1. 01 Every env var the app reads is set on the actual host, not just your local .env.
  2. 02 No literal "localhost" left in the codebase or the built bundle.
  3. 03 Every pending migration has run against the production database, not just dev.
  4. 04 The real production build ran locally at least once, not just npm run dev.
  5. 05 One clean npm ci, and every warning it prints got read before you deployed.

None of this replaces a real deploy gate, and the readiness question underneath it is bigger than five checks; the scorecard locates your weakest area in a couple of minutes if you’d rather start there. Run these once, and the gap between “works on my machine” and “works” gets a lot smaller.