When a Railway deploy fails with npm ci errors that don’t reproduce locally, the cause is almost always a different npm version. Pin Railway’s exact npm and run a dry-run to surface the same validation locally.
The Commands
# 1. Find Railway's npm version
railway ssh
# inside the container:
npm -v
exit
# 2. Replay Railway's npm ci against your current lock file
npx -y [email protected] ci --dry-run
# 3. Replay it in production mode (matches how Railway actually installs)
npx -y [email protected] ci --omit=dev --dry-run
Replace 10.9.7 with whatever npm -v printed inside the container. The npx -y npm@<version> form fetches that exact npm without touching your global install.
Why This Works
npm ci --dry-run runs the same EUSAGE strict-lock validation that produces errors like Missing X from lock file, but stops before any disk writes. If the dry-run passes, the real install will pass. If it fails, you see the exact error Railway would emit.
--omit=dev matters because Railway sets NODE_ENV=production, which implicitly triggers --omit=dev. Some lock-file inconsistencies only surface in production mode because they involve dev-only branches of the dependency graph.
The Gotcha
Running npm install locally is not a substitute. npm install rewrites the lock to fit your local resolution; npm ci refuses to and errors out. A clean npm install followed by git status showing no changes is not proof that npm ci will pass elsewhere. Different npm versions compute different “ideal trees”, especially around optional peer dependencies (peerDependenciesMeta: { foo: { optional: true } }). What npm 11 prunes as unneeded, npm 10 may insist must be installed.
When to Reach For This
- Deploy fails with
npm error Missing X from lock fileornpm error can only install packages when your package.json and package-lock.json are in sync - Lock-file changes mysteriously appear after
npm installon some machines but not others - A dependency was added or removed and the lock looks reasonable, but CI rejects it
Related
If you find optional peer dependencies are the source of churn, the fix is usually upstream: wait for the package author to widen the peer range, or pin a different version of the parent package. Adding the transitive dep to your own package.json works as a last resort but adds noise.