“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 isundefinedin 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=productiondisables 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
- Are all environment variables present on the server, with the right names and no quotes?
- Does
npm run build && npm startsucceed locally? - Is the server binding to
0.0.0.0and toprocess.env.PORT? - Does the production database have SSL enabled and all migrations applied?
- Do CORS origin,
credentials, and cookiesecure/sameSiteall agree? - Do all import paths match file casing exactly?
- Are timestamps stored in UTC?
- Is the process being killed for memory?
- 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”.