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_modulesWhy it is nasty
npm cistill 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.jsonshows a coherent tree, so suspicion lands on npm hoisting, the registry, or the shared BuildKit npm cache mount — all innocent. npm lssaysextraneous, notwrong 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-cachedoes not help either. - Any version-skewed package can be the messenger. Here two Sentry major lines coexisted (
@sentry/capacitoron v8,@sentry/sveltekiton v10), so the hoisted v8@sentry/nodewas 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.