pm(ci): make nub ci produce a project-local, COPY-safe node_modules#261
Conversation
`nub ci` is the frozen, ephemeral, deploy-oriented install; its node_modules is almost always relocated across a multi-stage Docker `COPY --from` or thrown away. Under the isolated+GVS default (#238) the `.nub/<dep>` entries are absolute symlinks into the machine-global virtual store (`~/.cache/nub/pm/virtual-store`), which does not travel with the COPY, leaving every dep dangling (#241). Force the virtual store project-local for `nub ci` (GVS off) via a new `engine_session_ci` that threads `VirtualStoreLocality::ProjectLocal` into the embedder-defaults tier, pushing `enableGlobalVirtualStore=false` alongside the existing isolated+hoist=false layout. Result: real `node_modules/.nub/<dep>` dirs + relative symlinks — a self-contained, COPY-relocatable tree that keeps the isolated layout's phantom-dep protection. Plain `nub install` is unchanged (GVS stays the default). An explicit user `enableGlobalVirtualStore`/`nodeLinker` still wins (embedder-tier default, mirroring how `aube dlx` defaults GVS off). Refs #241
The multi-stage COPY-relocatability of node_modules follows from the ci GVS-off change: document `nub ci` as the self-contained install for the build stage. Refs #241
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
There was a problem hiding this comment.
ℹ️ No critical issues — one doc gap worth a look. The code change is sound and well-tested.
Reviewed changes — makes nub ci install with a project-local virtual store (GVS off) so its frozen node_modules survives a multi-stage-Docker COPY --from (#241), while leaving plain nub install on the machine-global store.
- Add
VirtualStoreLocalityenum +engine_session_ci— a newDefault/ProjectLocalknob threaded throughengine_session_inner→nub_setting_defaults;run_ciis the only caller that requestsProjectLocal, pushingenableGlobalVirtualStore=falseat the embedder-defaults tier so a userenableGlobalVirtualStore/nodeLinkerstill wins. - Preserve isolation — the isolated +
hoist=falselayout (phantom-dep protection) is unchanged; only the store location moves. - Add
ci_forces_project_local_store_but_keeps_isolationtest — asserts ci forces GVS off + keepsisolated/hoist=false, and that a plain install never forces GVS off. - Docs — new "Multi-stage builds" section recommending
nub ciin the build stage.
I traced the mechanism through the vendored aube: resolve_global_virtual_store_override reads the resolved enable_global_virtual_store setting as the explicit override, so an embedder default of false yields planned_gvs=false regardless of !$CI — the change does what the description claims, and set_embedder_defaults being the lowest-priority tier confirms user config wins. The new engine_session_ci mirrors the existing engine_session_transient/_quiet/_global wrapper pattern, so it's idiomatic for this file.
Claude Opus | 𝕏
| WORKDIR /app | ||
| COPY --from=build /app/node_modules ./node_modules | ||
| COPY . . | ||
| CMD ["nub", "run", "start"] |
There was a problem hiding this comment.
ℹ️ The final stage runs nub but never installs it. It starts from a bare node:26-slim and only COPY --from=build /app/node_modules — but a global npm install -g @nubjs/nub lives in /usr/local (bin + lib), which is not under /app/node_modules, so nub won't be on PATH here and CMD ["nub", "run", "start"] fails with nub: command not found.
Technical details
# Final stage lacks nub on PATH
## Affected sites
- site/content/docs/docker.mdx:63-67 — final `FROM node:26-slim` stage copies only `/app/node_modules` + source, then `CMD ["nub", "run", "start"]`.
## Required outcome
- The final stage must have the `nub` binary available, or the example must not invoke it.
## Suggested approach (optional)
- Add `RUN npm install -g @nubjs/nub` to the final stage, OR base the final stage on `ghcr.io/nubjs/nub` (which bakes nub in, matching the page's other examples). The former keeps the base symmetric with the build stage; the latter is shorter and consistent with the top-of-page official-image example.There was a problem hiding this comment.
Pull request overview
Makes nub ci produce a relocatable, project-local node_modules by forcing the isolated layout’s virtual store to be project-local (GVS off) for CI installs, so multi-stage Docker COPY --from doesn’t leave dangling links.
Changes:
- Add a dedicated
engine_session_cithat threads aVirtualStoreLocality::ProjectLocalchoice into embedder defaults (disablingenableGlobalVirtualStore). - Switch
run_cito use the CI session and add unit coverage for the new defaulting behavior. - Document multi-stage Docker builds to prefer
nub cifor copy-safe dependency trees.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.
| File | Description |
|---|---|
| site/content/docs/docker.mdx | Adds a “Multi-stage builds” section recommending nub ci for COPY-safe node_modules. |
| crates/nub-cli/src/pm_engine/mod.rs | Introduces engine_session_ci, threads VirtualStoreLocality into session/default resolution, and adds tests. |
| crates/nub-cli/src/pm_engine/install_family.rs | Updates nub ci to use engine_session_ci so the new locality behavior actually applies. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| /// install; its `node_modules` is almost always COPY-relocated (multi-stage | ||
| /// Docker) or thrown away, and a machine-global virtual store makes that tree | ||
| /// non-relocatable — every `.nub/<dep>` becomes an absolute symlink into | ||
| /// `~/.cache/nub/pm/virtual-store` that a `COPY --from` leaves dangling (#241). |
| // contained tree that survives a multi-stage-Docker `COPY --from`, unlike | ||
| // the machine-global store the default install engages (#241). Isolation | ||
| // (phantom-dep protection) is unchanged; only the store LOCATION moves. |
| // `nub ci` (VirtualStoreLocality::ProjectLocal) pushes | ||
| // `enableGlobalVirtualStore=false` so the frozen node_modules is | ||
| // COPY-relocatable (#241), while keeping the isolated+hoist=false layout | ||
| // (phantom-dep protection). A plain install (Default) leaves GVS on. |
| // `engine_session_ci` forces a project-local virtual store (GVS off) so | ||
| // ci's frozen node_modules is COPY-relocatable across multi-stage Docker | ||
| // (#241); isolation/phantom-dep protection is preserved. |
| Install with `nub ci` in the build stage. It writes a self-contained `node_modules` — real files, project-local links — that survives a `COPY --from` into the final stage. A plain `nub install` shares packages through a machine-global store the final stage doesn't have, so the copied tree would point at files that aren't there. | ||
|
|
| FROM node:26-slim | ||
| WORKDIR /app | ||
| COPY --from=build /app/node_modules ./node_modules | ||
| COPY . . |
|
Shipped in v0.3.0: https://github.com/nubjs/nub/releases/tag/v0.3.0 |

Under the isolated + global-virtual-store default (#238), a
nub cinode_moduleshas.nub/<dep>entries that are absolute symlinks into the machine-global store (~/.cache/nub/pm/virtual-store). That store does not travel with a multi-stage DockerCOPY --from, so every dependency dangles in the final image (#241).nub ciis the frozen, deploy-oriented install whose output is almost always relocated or thrown away, so the global store buys nothing there. This forces its virtual store project-local (GVS off): real.nub/<dep>dirs + relative symlinks — a self-contained tree that survives theCOPY. Isolation / phantom-dep protection is preserved; only the store location moves. Plainnub installis unchanged (GVS stays the default), and an explicit userenableGlobalVirtualStore/nodeLinkerstill wins.Mechanism
A new
engine_session_cithreadsVirtualStoreLocality::ProjectLocalinto the embedder-defaults tier, pushingenableGlobalVirtualStore=falsealongside the existing isolated +hoist=falselayout. Onlyrun_ciuses it. Embedder-tier, so any user source overrides it — mirroring howaube dlxdefaults GVS off for scratch installs.Verification
Linux Docker (
node:22-slim, multi-stageCOPY --from=build /app/node_modules):require('express')nub cinub installCannot find module 'express'Local ad-hoc confirms user
enableGlobalVirtualStore=trueandnode-linker=hoistedunder ci both override correctly. Adds a Multi-stage builds section to the Docker docs.Refs #241