Skip to content

A bare node_modules line in .dockerignore does not match nested node_modules, so a later COPY silently clobbers npm ci

Symptom

A freshly built dev image ran code that contradicted package-lock.json. Vite refused to start:

failed to load config from /opt/finfam/src/fruit/vite.config.ts
TypeError: _optionalChain is not a function
    at Object.<anonymous> (/…/node_modules/@sentry/node/build/cjs/integrations/tracing/prisma.js:16:3)
    at Object.<anonymous> (/…/node_modules/@sentry/node/build/cjs/index.js:26:16)

Inside the image, require('@sentry/sveltekit/package.json').version reported 8.55.0, while package.json and every package-lock.json entry pinned 10.69.0, and npm ls flagged the 10.69.0 packages as extraneous. The image had been built minutes earlier and npm ci had reported success.

Root cause

The Dockerfile follows the standard cache-friendly ordering:

COPY fruit/package.json fruit/package-lock.json /opt/finfam/src/fruit/
RUN --mount=type=cache,id=npm-fruit,target=/root/.npm,sharing=locked npm ci
COPY fruit /opt/finfam/src/fruit          # <-- clobbers the line above

.dockerignore contained:

node_modules

.dockerignore patterns are matched against paths relative to the build context root, and a bare node_modules is anchored there. It excludes /node_modules and nothing else. fruit/node_modules was never excluded, so the third COPY layered a months-old host node_modules on top of the tree npm ci had just resolved from the lockfile.

This is the opposite of .gitignore, where a bare node_modules matches at any depth. The two files look alike and share syntax heritage, which is exactly why the bug survives review.

Fix

-node_modules
+**/node_modules

Why it is nasty

  • npm ci still exits 0. The install genuinely succeeded; a later layer overwrote it. No error anywhere in the build log.
  • The lockfile is exonerated by inspection. Auditing package-lock.json shows a coherent tree, so suspicion lands on npm hoisting, the registry, or the shared BuildKit npm cache mount — all innocent.
  • npm ls says extraneous, not wrong version. That wording points at dependency resolution rather than at file provenance.
  • Reproducibility is a function of the host. CI, whose checkout has no node_modules, builds a correct image. Only developer machines with a stale tree are poisoned, and the image tag is identical. --no-cache does not help either.
  • Any version-skewed package can be the messenger. Here two Sentry major lines coexisted (@sentry/capacitor on v8, @sentry/sveltekit on v10), so the hoisted v8 @sentry/node was loaded by the v10 plugin. Disabling Sentry would only have relocated the crash.

Detection

Inside a suspect image, compare provenance rather than reading the lockfile:

docker run --rm --entrypoint bash IMAGE -lc \
  'cd /app && node -e "console.log(require(\"pkg/package.json\").version)"' \
  && node -e 'console.log(require("./package-lock.json").packages["node_modules/pkg"].version)'

A mismatch between image and lockfile, with a host tree that matches the image, is the signature.

Generalization

Applies to every ignore entry meant to be recursive and to every ecosystem's build-output directory: **/node_modules, **/__pycache__, **/.venv, **/target, **/vendor, **/dist. Audit .dockerignore for bare directory names in any repo whose Dockerfile does a broad COPY <subdir> after a dependency-install step. Docker's ** support has been in BuildKit and the classic builder for years, so the recursive form is safe to adopt unconditionally.

No signals yet