"It worked on my machine" isn't an excuse. It's a clue. It means two environments disagree about something, and the build depends on that something. The fix is never to rerun CI and hope. It's to find the difference and remove it.
In a JavaScript project, the differences almost always fall into three places: dependencies, the runtime, and the operating system.
1. Dependencies: commit the lockfile, install with npm ci
package.json says what you'd accept, like "next": "^15.5.4". That caret means "15.5.4 or any later 15.x". Two
installs a week apart can get different versions.
package-lock.json records exactly what was installed, including every dependency of a dependency. Commit it.
Then, in CI and deployments, install with npm ci instead of npm install:
npm ci
npm ci installs exactly what the lockfile says. It removes any existing node_modules first, so nothing is left over
from an old install. It fails if package.json and the lockfile disagree, instead of quietly updating the lockfile.
That failure is a feature: it tells you someone changed a dependency without committing the lockfile.
2. The runtime: pin Node
The same lockfile can behave differently on Node 18 and Node 22. Write the version down where tools can read it:
echo "22" > .nvmrc
{
"engines": { "node": ">=22 <23" }
}
Version managers like nvm read .nvmrc, many hosting platforms read engines, and CI should use the same version.
3. The operating system: Docker, pinned
macOS is case-insensitive by default and Linux isn't, so import "./Button" can find button.tsx on your Mac and fail
on the build server. Native modules compile differently on each platform. Containers make the operating system part of
the build:
FROM node:22.11-bookworm-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
Pin the base image to a specific version, not latest. Docker's own best-practice guide recommends pinning, and goes
further: pinning to an image digest gives you exactly the same bytes every time.
Copying the lockfile and running npm ci before copying the rest of the code also means Docker can reuse the install
layer when only your source changes, which makes builds faster.
What "reproducible" really means
The Reproducible Builds project defines a reproducible build as one where the same source code, build environment and build instructions let anyone recreate bit-for-bit identical output. Most web teams don't need bit-for-bit, but the idea scales down: the same inputs should give the same result, on any machine.
A quick checklist
package-lock.jsonis committed and changes are reviewed.- CI and deploys run
npm ci. - The Node version lives in
.nvmrcandengines, and CI uses it. - The Docker base image is pinned.
- Environment variables are documented in
.env.example, so a missing one is obvious.
None of this is glamorous. All of it means "works on my machine" becomes "works on every machine."










