Pitfalls and Solutions Moving a pnpm Monorepo to Nub
27 de julio de 2026
0
Computing/SoftwareComments (0)
Log in to leave a comment
No posts yet
Log in to leave a comment
No posts yet
The Node.js ecosystem is exhausted. Build environments layered with tsx, dotenv-cli, nvm, and pnpm are heavy, and scripts break whenever you update even a single piece. When Nub—an integrated toolkit written in Rust—appeared promising to bundle this package mess into a single binary, I was honestly both welcomed and skeptical.
When I actually migrated our production pnpm-workspace monorepo to Nub, it turned out to be quite attractive. However, nothing ever goes completely smoothly. We immediately ran into real-world hurdles, including dependency conflicts, 24-hour security lock policies, and CI cache separation issues.
Nub's package engine, aube, immediately recognizes the existing pnpm-workspace.yaml and the workspaces field in package.json. True to the explanation that it is byte-for-byte compatible with the pnpm-lock.yaml v9 schema, it natively utilizes the existing virtual Store structure (node_modules/.store/).
The problem lies with legacy tools that implicitly assume a hoisted node_modules structure. Running them right after migration throws a MODULE_NOT_FOUND error and crashes. To get your service up and running immediately, you should start by flattening the package tree with the --node-linker hoisted option.
On a warm installation with caching applied, the default GVS mode takes 346 ms. Hoisted mode takes 1461 ms—more than 4 times slower—but it is still more than twice as fast as pnpm v10+'s 3453 ms. It is much easier to first secure compatibility with Hoisted mode and then switch to default GVS mode as you remove legacy tool dependencies.
`bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> [1/4] Checking Nub binary"
if ! command -v nub &> /dev/null; then
echo "Error: Nub is missing. Run 'npm i -g @nubjs/nub' first."
exit 1
fi
echo "==> [2/4] Checking pnpm-lock.yaml schema"
if [ -f "pnpm-lock.yaml" ]; then
nub pm use nub
fi
echo "==> [3/4] Specifying execution engine in package.json"
node -e '
const fs = require("fs");
const pkg = JSON.parse(fs.readFileSync("package.json", "utf8"));
pkg.devEngines = pkg.devEngines || {};
pkg.devEngines.packageManager = {
name: "nub",
version: "^0.4.0",
onFail: "warn"
};
fs.writeFileSync("package.json", JSON.stringify(pkg, null, 2) + "\n");
'
echo "==> [4/4] Generating Lockfile"
nub install --frozen-lockfile=false
`
Converting pnpm-lock.yaml to nub.lock using this script can save you over 2 hours a week that used to be wasted fixing broken scripts every time.
Nub has pretty strict security policies. It blocks lifecycle scripts by default and throws ERR_NUB_TRUST_DOWNGRADE if an npm Provenance signature is missing. The most confusing part is the minimumReleaseAge option. It blocks the installation of package versions published less than 24 hours ago, treating them as pending security verification.
While it is a great shield during normal times, it turns into a wall of tears when a zero-day vulnerability hits and you need to deploy a patch version released an hour ago immediately. In this case, you must explicitly list the allowed build script packages in the root package.json and bypass it using commands.
`json
{
"name": "@org/monorepo-root",
"private": true,
"allowBuilds": {
"esbuild": true,
"sharp": true,
"@fast-cve/patch-pkg": true
}
}
`
Here is the workflow to bypass and apply a blocked hotfix package:
`bash
nub add --allow-build=@fast-cve/patch-pkg @fast-cve/patch-pkg@1.0.1-hotfix
nub approve-builds
`
Running nub approve-builds unblocks the list and proceeds with the build immediately. Packages registered as malware (MAL-*) in the OSV database cannot be bypassed even with this command, so in those cases, you must find a higher alternative version.
To boost CI/CD speed, you need to know Nub's cache paths. Node binaries go into ~/.cache/nub/node/<version>/, while the package CAS (Content-Addressable Store) is split between ~/.cache/nub/ and node_modules/.store/.
In Docker multi-stage builds, you should use BuildKit's cache mounts to completely isolate the installation layer to benefit from caching.
`dockerfile
FROM ghcr.io/nubjs/nub:latest AS base
WORKDIR /app
FROM base AS dependencies
COPY package.json nub.lock pnpm-workspace.yaml ./
COPY packages/core/package.json ./packages/core/
COPY packages/api/package.json ./packages/api/
RUN --mount=type=cache,target=/root/.cache/nub
nub ci --prefer-offline
FROM dependencies AS builder
COPY . .
RUN nub run build --filter=@org/api
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/packages/api/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
EXPOSE 3000
CMD ["node", "dist/index.js"]
`
In GitHub Actions, use nubjs/setup-nub@v0 instead of actions/setup-node.
`yaml
name: CI
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: nubjs/setup-nub@v0
with:
cache: true
- run: nub ci
- run: nub -r run build
- run: nub -r run test
`
The speed of nub ci combined with setup-nub and caching is around 346 ms. Compared to the existing pnpm (3453 ms), package installation time in the pipeline drops significantly, cutting total CI build time in half. Seeing the cloud infrastructure bill at the end of the month definitely puts you in a good mood.
Nub includes a built-in oxc-based in-memory transpiler, running TypeScript code directly without tsx or ts-node. It also reads .env files automatically. As the Node.js process bootstrap overhead that popped up every time when using pnpm run (442.7 ms) disappears, script execution time for nub run drops to 14.7 ms. The difference is quite noticeable.
To prevent Node.js version fragmentation across team members, simply place a .node-version file at the root.
`bash
echo "22.15.0" > .node-version
nub src/index.ts
`
On Node 22.15.0 and above, synchronous module.registerHooks() completely eliminates cold start latency. Setting up an automation script is the cleanest way to standardize the environment for new teammates without lengthy explanations.
`bash
#!/usr/bin/env bash
set -euo pipefail
echo "==> Starting development environment setup"
if ! command -v nub &> /dev/null; then
if command -v brew &> /dev/null; then
brew install nubjs/tap/nub
else
npm install -g --ignore-scripts=false @nubjs/nub
fi
fi
nub pm shim
nub node install
nub install
if [ ! -f ".env.local" ] && [ -f ".env.example" ]; then
cp .env.example .env.local
fi
echo "==> Setup complete. Run with 'nub run dev'."
`
Once you run nub pm shim, even if you habitually type pnpm install or npm run, it will be intercepted and handled by the Nub runner. On top of reducing CLI execution overhead from pnpm exec (191 ms) to nubx (11 ms), you can save at least 2 hours a week debugging issues like "It works on my machine."