The short answer#
It is almost always an environment parity problem: your machine and the build server aren't identical. The usual suspects are environment variables that exist on your laptop but not on the server, tools installed globally or as devDependencies, different Node or Python versions, files blocked by .gitignore, and Linux's case sensitivity: macOS and Windows forgive wrong casing in imports, the server usually doesn't.
The fix is to reproduce the production build environment locally and remove implicit assumptions. Below are the ten causes, how to diagnose each, and a checklist to work through them in order.
Documentation: The Twelve-Factor App · Dev/prod parity ↗ · npm · npm ci ↗
The root cause: it works on my machine#
A build that passes on your laptop and fails on the server is one of the oldest problems in software. The underlying reason never changes: your local environment and the build environment aren't the same. Your machine accumulates config, variables exported in your shell, global dependencies and paths you take for granted; the server starts clean, from the repository and nothing else.
Diagnosing it means eliminating differences one at a time. These ten account for most cases.
1. Environment variables that only exist on your machine#
You have a variable in your local .env or exported in your shell, and the code reads it during the build. On the server it isn't set, so the build fails or produces a broken artifact.
How to diagnose: find every read of process.env (Node) or os.environ (Python) that runs at build time, including bundler config files.
How to fix: declare every variable explicitly in the deploy environment's settings. Never commit .env; document the required variables in a committed .env.example instead.
# .env.example (this one IS committed; the real .env is not)
DATABASE_URL=
API_KEY=
NODE_ENV=productionDocumentation: The Twelve-Factor App · Dev/prod parity ↗
2. NODE_ENV=production changes behavior#
Many Node apps and tools behave differently depending on NODE_ENV. A bug may only surface when it is production, for example during minification or asset optimization, and never show up in development. npm changes too: with NODE_ENV=production it omits devDependencies by default when installing.
How to fix: reproduce it locally by forcing the same value before building, with NODE_ENV=production npm run build. If it fails the same way as on the server, you've isolated the case on your machine.
Documentation: Node.js · Development vs production ↗ · npm · config: omit / include ↗
3. The build tool lives in devDependencies#
Your build needs a package, such as a bundler or the TypeScript compiler, but it's listed in devDependencies. If the server installs in production mode (NODE_ENV=production or --omit=dev), the package never arrives and the build fails with command not found or module not found.
How to fix: there are two valid routes. The cleaner one is to install devDependencies in the build step and leave them out of only the final runtime image. If your platform doesn't let you control the install, move what the build needs into dependencies.
# Option A: install devDependencies on the build server too
npm ci --include=dev
npm run build
# Option B: move the build tool into dependencies
npm install --save typescriptDocumentation: npm · package.json devDependencies ↗ · npm · config: omit / include ↗ · npm · npm ci ↗
4. Case sensitivity: Linux doesn't forgive#
This is the classic silent one. macOS and Windows use case-insensitive file systems by default, so import Button from './components/button' works even when the file is called Button.tsx. Build servers usually run Linux, which is case-sensitive, and the same import fails with module not found.
There's a sneakier variant: you renamed button.tsx to Button.tsx on your Mac, but Git didn't record it because core.ignoreCase is on there. The file has one name on your disk and another in the repository.
# What is the file actually called in the repository?
git ls-files | grep -i 'components/button'
# Rename a file by case only (on macOS/Windows you need git mv)
git mv src/components/button.tsx src/components/Button.tsxTo prevent it: make every import match the file name character for character, enable forceConsistentCasingInFileNames in TypeScript, and run the build in CI on Linux so the error shows up before deploy.
Documentation: Git · core.ignoreCase ↗ · TypeScript · forceConsistentCasingInFileNames ↗ · Git · git check-ignore ↗
5. A different Node, Python or runtime version#
You run one version locally and the server defaults to another. An API that exists in your version may be missing in the build's, or the other way round, and a dependency may require a minimum version.
How to fix: pin the version in the project so your machine, CI and the server all read the same one.
# .nvmrc (read by nvm and actions/setup-node)
22
// package.json
{
"engines": { "node": ">=22 <23" }
}
# .python-version (pyenv and several platforms)
3.12Check your local version with node --version or python --version and confirm in your platform's docs which of these files it reads.
Documentation: npm · package.json engines ↗ · nvm · .nvmrc ↗ · GitHub · actions/setup-node ↗
6. A required file is blocked by .gitignore#
The file is on your disk, but an overly broad .gitignore rule keeps it out of the repository. Since the build starts from the repository, the file simply doesn't exist on the server.
The classic example: a lib rule meant to ignore the build output folder at the root also ignores every folder named lib at any depth, such as src/lib with code your app needs. A pattern without a slash matches at any level; a leading slash anchors it to the .gitignore's directory.
# Before: ignores every "lib" folder at any depth
lib
# After: ignores only /lib at the project root
/lib
# Which rule is ignoring this file?
git check-ignore -v src/lib/format.tsConfirm what is actually committed with git ls-files.
Documentation: Git · gitignore ↗ · Git · git check-ignore ↗
7. A stale or uncommitted lockfile#
If package-lock.json, yarn.lock or poetry.lock isn't committed, or is out of sync with package.json, the server can resolve different versions than yours and break the build.
How to fix: always commit the lockfile and install with the command that honors it exactly.
npm ci # installs exactly what the lockfile says; fails if it disagrees with package.json
# instead of:
npm install # may resolve newer versions and rewrite the lockfileDocumentation: npm · npm ci ↗ · npm · package-lock.json ↗
8. Native dependencies or missing build tools#
Some packages compile native code (C or C++) at install time and need tools such as gcc, make or python that your machine has and the server image doesn't. Others download prebuilt binaries for a specific OS and CPU architecture: the binary on your Apple Silicon Mac won't run on an x86_64 Linux server.
How to fix: read the build logs to find the failing package, make sure the base image includes the required build tools or use a prebuilt binary for the server's platform, and never copy node_modules from your machine: always install on the server.
Documentation: npm · npm ci ↗
9. The build runs out of memory#
Your laptop usually has far more RAM than the build environment. Large builds, especially frontend ones, can die with a memory error you would never see locally.
How to diagnose: look for JavaScript heap out of memory in the logs, or a process that dies without explanation, the typical sign of an OOM kill.
How to fix: reduce the build's footprint (split bundles, limit concurrency) or raise Node's heap limit with NODE_OPTIONS=--max-old-space-size=4096 npm run build. The value is in MiB and must fit in the build environment's real memory; set it higher and the system will kill the process anyway.
Documentation: Node.js · --max-old-space-size ↗
10. Build-time variables that never reach the build#
This differs from cause 1: some platforms inject variables only at runtime, when the app runs, not during the build. If the build needs a variable, such as a frontend's public API URL, and it only exists at runtime, the build silently uses an empty value.
In Next.js, NEXT_PUBLIC_ variables are inlined into browser code at build time; Vite does the same with VITE_ variables. Changing them after the build has no effect: you have to rebuild. Check your platform's docs for which variables are available at build time and declare the ones the build needs there.
Documentation: Next.js · Environment variables ↗ · Vite · Env variables and modes ↗
Reproduce the build clean, and in CI#
The rule of thumb: if you can't reproduce the build in a fresh directory, with no existing node_modules, no variables exported in your shell and the same runtime version, the problem isn't the server; it's your local setup.
# Reproduce the build the way the server does: fresh clone, no node_modules, no .env
git clone --depth 1 <repo-url> /clean/path && cd /clean/path
node --version # compare with the server's version
env -i PATH="$PATH" HOME="$HOME" NODE_ENV=production \
sh -c 'npm ci --include=dev && npm run build' Better still, run the same build in CI on Linux on every push. That way casing, version and lockfile errors show up in the pull request, not during deploy.
# .github/workflows/build.yml
name: build
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version-file: .nvmrc
cache: npm
- run: npm ci
- run: npm run build
env:
NODE_ENV: productionDocumentation: GitHub · actions/setup-node ↗ · npm · npm ci ↗
Quick diagnostic checklist#
When a build passes locally and fails on deploy, work through this in order:
- Can you reproduce it with NODE_ENV=production npm run build in a clean clone?
- Are all variables the build needs set on the server, in the right phase?
- Do build tools get installed on the server (dependencies or --include=dev)?
- Do imports match the repository's file names exactly, including case?
- Is the Node or Python version pinned and matching your local one?
- Are required files blocked by .gitignore?
- Is the lockfile committed, and do you use npm ci?
- Do the logs mention memory, build tools or a native package?
If you reach the end without finding it, the likely culprit is an implicit global dependency or a build script different from the one you run locally.
Frequently asked questions#
Why does my import work on a Mac but fail on the server? Because macOS and Windows ignore filename case by default and Linux doesn't. An import of ./Button won't find a file named button.tsx on Linux. Fix the path and, if you renamed the file, do it with git mv.
What's the difference between npm install and npm ci for deploys? npm install can update versions and rewrite the lockfile; npm ci installs exactly what the lockfile says, removes node_modules first and fails if the lock disagrees with package.json. For reproducible builds, use npm ci.
Why does the build run out of memory only on the server? Because the build environment usually has less RAM than your machine. Shrink the build or tune the heap limit within the memory available.
Should I commit my .env so the deploy works? No. Set the variables in your platform's settings and commit only a .env.example with no values.
Close the gap between local and production#
Every build that fails only on the server is the cost of an undocumented assumption: a variable that only lived in your shell, a global dependency you installed months ago, or an import your file system forgave. Environment parity isn't a luxury; it's the baseline for predictable deploys.
The goal isn't for the server to look like your machine but for both to behave the same from one explicit definition: pinned versions, declared variables, a committed lockfile and a build that runs in CI.
Documentation: The Twelve-Factor App · Dev/prod parity ↗
Sources and scope
Documentation checked on September 25, 2026. Examples and decision criteria are editorial proposals; adapt them to your application's contract and validate them in an authorized test environment.
- The Twelve-Factor App · Dev/prod parity ↗
- npm · npm ci ↗
- Node.js · Development vs production ↗
- npm · config: omit / include ↗
- npm · package.json devDependencies ↗
- Git · core.ignoreCase ↗
- TypeScript · forceConsistentCasingInFileNames ↗
- Git · git check-ignore ↗
- npm · package.json engines ↗
- nvm · .nvmrc ↗
- GitHub · actions/setup-node ↗
- Git · gitignore ↗
- npm · package-lock.json ↗
- Node.js · --max-old-space-size ↗
- Next.js · Environment variables ↗
- Vite · Env variables and modes ↗
Compare cloud options
Review pricing, limits, conditions and sources for each option (in Spanish).
Open comparison