Every npm 12 migration guide stops in the same place: run npm approve-scripts, commit the allowScripts block, done. The interesting week is the one after, when npm ci exits 0, the pipeline is green, and the container falls over on first request with Cannot find module '../build/Release/bcrypt_lib.node'.
That happens because of a deliberate design choice, documented in GitHub's June 9 changelog for npm v12: an unapproved install script is skipped with a warning, and the install still succeeds. Socket's write-up of the release describes the same default as the softer of the two options npm considered. Your build ends up producing a node_modules tree that is structurally complete and functionally hollow.
The trap is that three unrelated failures print near-identical "this native module won't load" output, and npm 12 only added the third one a few months ago, so threads, docs and model answers all run them together. If you have not committed your approvals yet, the npm allowScripts warning walkthrough covers the schema and the warning window; what follows is what happens after that, plus the CI gates that catch each cause before a deploy does. Tips 1 to 3 are the triage, and everything after assumes you did it.
flowchart TD
A["Native module fails to load"] --> B{"Which error string?"}
B -->|"Cannot find module build/Release"| C["Install script was blocked"]
B -->|"Could not load the module using the linux-x64 runtime"| D["Optional dep never resolved"]
B -->|"Error loading shared library"| E["Built against the wrong libc"]
C --> F["approve-scripts then npm rebuild"]
D --> G["Regenerate the lockfile from scratch"]
E --> H["Build inside the runtime image"]
| Symptom | Real cause | The fix that works |
|---|---|---|
Cannot find module '../build/Release/*.node' |
install script or implicit node-gyp rebuild skipped |
allowScripts plus npm rebuild
|
Could not load the "sharp" module using the linux-x64 runtime |
platform optional dep absent from the lockfile | regenerate package-lock.json
|
Error loading shared library libstdc++.so.6 |
glibc binary running on musl | rebuild in the runtime image |
The tips
1. Read the error string before you touch allowScripts. Cannot find module '../build/Release/foo.node' means a compile step never ran, which is an npm 12 problem. Could not load the "sharp" module using the linux-x64 runtime and Cannot find module '@rollup/rollup-linux-x64-gnu' mean a whole package is absent from the tree, and approvals have no bearing on that. Adding entries to allowScripts for the second class is the most common wasted afternoon in this migration right now. If you are still unsure which command owns which behaviour, approve-scripts versus --allow-scripts draws the line.
2. Approving sharp accomplishes nothing. sharp's own install documentation states that its prebuilt binaries arrive through optional dependencies and asks you to ensure your package manager is configured to install optional dependencies. There is no install script there to approve. The packages that genuinely need approval are the node-gyp and prebuild-install crowd: bcrypt, better-sqlite3, canvas, node-pty, dtrace-provider. Check which family you are in before editing anything:
node -p "require('./node_modules/sharp/package.json').scripts || 'no scripts'"
3. Sweep for binding.gyp files, because scripts.install misses half of them. npm 12 blocks the implicit node-gyp rebuild that npm runs for any dependency shipping a binding.gyp, even when that package declares no install script at all. A jq pass over scripts.install in your dependency manifests will report a clean tree and be wrong:
find node_modules -maxdepth 3 -name binding.gyp -printf '%h\n' | sed 's|node_modules/||'
Every path that prints is a package that compiles at install time and needs an allowScripts decision.
4. Make CI fail loudly with --strict-allow-scripts. The default warning is written for a human watching a terminal scroll past, which describes nobody in your pipeline. Strict mode turns the same condition into an ESTRICTALLOWSCRIPTS failure:
npm ci --strict-allow-scripts
5. Expect strict mode to reject a package your pending list cannot show you. npm/cli issue #9562 documents exactly this: on a Linux runner, npm ci --strict-allow-scripts rejected fsevents@2.3.3 for lacking approval, while npm approve-scripts --allow-scripts-pending never listed it, because the package is a Darwin-only optional dependency and the pending scan skipped it. It was closed by PR #9597. If CI fails on a package your laptop refuses to list, approve it by name explicitly and move on rather than hunting for a config error you do not have.
6. Make the build load every native module, because exit code 0 proves nothing. Add a smoke step that does what production does on first request. Two lines converts a silent skip into a red build:
node -e 'for (const m of ["bcrypt","better-sqlite3","canvas"]) { require(m); console.log("ok", m) }'
Keep the module list in a file next to your Dockerfile and generate it from tip 3's binding.gyp sweep so it stays honest as dependencies change.
7. Run that smoke test in the runtime stage. A .node file compiled in a node:24-bookworm builder and copied into node:24-alpine loads fine in the builder and dies in the runtime image with Error loading shared library libstdc++.so.6. That is the third row of the table, it has nothing to do with npm 12, and it is being misdiagnosed as npm 12 all over the place this quarter because the timing lines up. Put the node -e require loop as the last RUN in the final stage, where the libc it links against is the one that will serve traffic.
8. Approving a package does not retroactively build it. allowScripts is consulted during installation, so editing package.json afterwards leaves the empty tree exactly as it was. Locally, npm rebuild <pkg> is the recovery. In CI, distrust any cached node_modules and start from a clean tree:
npm approve-scripts better-sqlite3 --no-allow-scripts-pin
npm rebuild better-sqlite3
9. Pinned allowScripts entries expire the moment Renovate bumps. An entry like "better-sqlite3@12.6.0": true covers that exact version. The next dependency bump produces a package matching nothing in your allowlist, the script is skipped, and the failure surfaces a deploy later on a PR that "only touched dependencies". Use name-only keys via --no-allow-scripts-pin for packages you have already reviewed, and save pinned keys for the genuinely untrusted ones where forcing a re-review is the point. The package.json schema breakdown has the key forms npm accepts.
10. Fix a missing platform binary at the lockfile. npm/cli issue #4828 is the mechanism behind Cannot find module '@rollup/rollup-linux-x64-gnu': regenerating package-lock.json while node_modules already exists records only the optional variants for the machine doing the regenerating. Committed from a Mac, the Linux runner then skips a dependency it can never resolve. The repair is destructive and has to be:
rm -rf node_modules package-lock.json && npm install
git add package-lock.json
For cross-platform lockfiles, sharp's docs point at --os, --cpu and --libc, so npm install --cpu=x64 --os=linux --libc=musl sharp forces the variant your Alpine image will look for.
11. Turn on --foreground-scripts when a build works but takes suspiciously long. prebuild-install quietly falls back to compiling from source when no prebuilt binary matches the platform, and npm hides that output by default. npm ci --foreground-scripts puts the compiler chatter in the CI log, which is how you discover a runner spent four minutes building a binary you assumed was downloaded.
12. Hunt down every leftover ignore-scripts. --ignore-scripts outranks allowScripts, and teams that hardened against supply-chain attacks in 2025 tend to have ignore-scripts=true sitting in a repo .npmrc, an NPM_CONFIG_IGNORE_SCRIPTS env var in the pipeline, or baked into a hardened base image. Every approval you commit gets ignored, and the warning that would normally tip you off never prints. Audit all three surfaces before you conclude npm is broken:
npm config get ignore-scripts
grep -rn "ignore.scripts" .npmrc .github/ Dockerfile* 2>/dev/null
env | grep -i npm_config
Wrap-up
Keep one habit from this list: make CI load every native module inside the runtime image. Approval lists drift as dependencies bump, lockfiles get regenerated on the wrong laptop, base images change libc, and all three failures share the property that npm reports success. A node -e require loop in the final stage is the only check that survives every one of them.
If the tree itself is your real problem rather than the scripts, the npm edgesOut crash breakdown separates that family. pnpm users reach the same wall through a different door, covered in the pnpm 12 upgrade traps.
Originally published at indragustiprasetya.com
Top comments (0)