Skip to content
Nate Comics cover art for issue #04: It worked on my machine: reproducible builds with lockfiles, npm ci and Docker

Issue #04

It worked on my machine: reproducible builds with lockfiles, npm ci and Docker

Published
Tags
Engineering, DevOps, Node.js
Read time
2 min

Why the same code passes on your laptop and fails in CI, and the three habits that make builds repeatable: commit the lockfile, use npm ci, pin your runtime.

"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.json is committed and changes are reviewed.
  • CI and deploys run npm ci.
  • The Node version lives in .nvmrc and engines, 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."

Questions readers ask

What is the difference between npm install and npm ci?
npm ci installs exactly what package-lock.json says, removes any existing node_modules first, and fails if the lockfile and package.json disagree. npm install can update the lockfile. Use npm ci in CI and deployments.
Should package-lock.json be committed?
Yes, for applications. The lockfile records the exact version of every dependency, so teammates, CI and production install the same tree.
What is a reproducible build?
A build that produces the same artifacts every time from the same source code, build environment and build instructions, so anyone can check that a binary came from the code it claims to.

Sources

  1. npm ci (opens in a new tab), npm Docs
  2. package-lock.json (opens in a new tab), npm Docs
  3. Definitions (opens in a new tab), Reproducible Builds
  4. Building best practices (opens in a new tab), Docker Docs

Open to senior roles · Remote, hybrid or on-site

Hiring a senior engineer who writes like this?

I'm Nathan Mabasa, a senior software engineer in Johannesburg. I build React and Next.js applications in TypeScript, plus the APIs, payments and security underneath them. Seven years across agencies and in-house teams.

One quick thing

Who's asking?

Leave your details and the CV downloads straight away. It helps me follow up about the role.

Your details go only to me, to follow up about roles. No mailing lists, nothing shared.