Skip to content
All posts
3 min read

npm won by forfeit

Three failed deploys in one evening taught me that the package manager production runs is not a preference. It is whatever survives the host's cage.

Engineering

Deploy one failed with a version error. Deploy two failed with a blocked-build-scripts error. Deploy three failed with EACCES, and that one was not mine to fix. All three had the same root cause: I wanted pnpm in production, and production is a CloudLinux cage on shared hosting that had other plans.

The setup: this site deploys by pushing to main. Hostinger pulls the repo and builds it inside an LVE cage, a resource-limited sandbox where you do not control the filesystem rules. I use pnpm locally and like it, so naturally I tried to make the deploy use it too.

Failure one. My package.json pinned packageManager to pnpm 10. Corepack treats that pin as law, the host's pnpm 11 refused to downgrade itself, and the install died before installing anything: Failed to install dependencies, four words, no elaboration. Deleted the pin.

Failure two. pnpm 11 ships with strict dependency builds: postinstall scripts in dependencies are blocked until you approve them, so sharp and esbuild, which both compile or download native binaries on install, got refused with ERRPNPMIGNORED_BUILDS. This one is legitimately good security, and it has a legitimate fix: an allowBuilds list in pnpm-workspace.yaml naming the packages whose scripts may run. Added it. Felt like progress.

Failure three was the wall. With scripts approved, sharp and esbuild died anyway: EACCES, permission denied, while trying to spawn their own binaries out of pnpm's nested .pnpm virtual store. That store layout, a deep tree of symlinked package directories, is the thing that makes pnpm fast and strict. The cage's security model does not permit executing from where the store puts the binaries. There is no flag for this. It is a philosophy collision between how pnpm lays out disk and how CloudLinux thinks disk may be used.

npm's flat node_modules never picks that fight. Binaries land in ordinary paths, postinstalls run where the cage expects programs to live, and the same build that failed three ways went green on the first try. npm did not win an argument. It won by forfeit.

So production now builds with npm while every machine I develop on runs pnpm, and I am at peace with the heresy. There is no package-lock.json in the repo; npm re-resolves dependencies each deploy, and the determinism lives in the committed pnpm-lock.yaml that my own machines use. The conventional rule says one package manager everywhere. I think the honest rule is: determinism where I debug, tolerance where I do not control the floor. A lockfile in a cage I cannot inspect protects me from less than it appears to.

The part worth keeping is that Turbopack came through the whole mess untouched. The build command is next build with the Turbopack flag, it runs happily inside the cage, and a push is live in about two minutes.

The last thing I did was write it down where it will actually be read. The platform's design document now contains a section that opens, in capitals, DO NOT switch the deploy to pnpm, followed by the three failures in order, with the exact error names. Some months from now the pin will look harmless again and I will want to unify the toolchain, because unifying toolchains always looks harmless from a distance. The document is there to make me re-earn the mistake instead of just repeating it.

On managed hosting, tools do not compete on merit. They compete on assumptions, and the winner is whichever one assumes the least about the filesystem it is standing on. I still like pnpm more. Production does not care what I like.

Remiel Shirazi

CTO & Co-founder | AI, Roblox & Piano