Node.js with Silo: npm, yarn, pnpm, and version pinning
Running Node under Silo — persisting node_modules with silo build, switching Node versions per project, and wiring yarn / pnpm via corepack or custom shims.
This is the long-form guide for the JavaScript ecosystem under Silo. Four topics:
- Running
node/npmand what survives between invocations. - Making dependencies persist — project
node_modulesvs.silo build. - Running different Node versions globally or per project.
- Wiring yarn, pnpm, and other package managers.
Prerequisites: Silo installed, ~/.silo/bin on your PATH. If not yet, start with Getting started.
Install Node
# Default (22-slim — current LTS track)
silo install node
# Specific major
silo install node@20
silo install node@18
Registry versions today: 23 (latest), 22 (LTS, default), 20, 18. All -slim variants.
After install, the node, npm, and npx shims land in ~/.silo/bin/. Because that’s on your PATH, every node, npm, or npx you type transparently routes through Silo.
Running Node
node --version
node -e "console.log('hello from the VM')"
node server.js
npm --version
npx create-vite@latest my-app
Interactive:
silo shell node # shell inside the VM
silo node # REPL
Tool shorthand:
silo node server.js # == silo run node server.js
silo npm test # == silo run --shim npm node test
silo npx tsc --watch # == silo run --shim npx node tsc --watch
What persists, what doesn’t
Every silo run is a fresh VM. Understanding what survives is the whole game with the Node ecosystem, which loves dropping files on disk.
| Thing | Persists? | Where |
|---|---|---|
Project files (including node_modules/ if unignored) | Yes — mounted rw | Host disk |
Global npm install -g target | No by default | Ephemeral VM |
~/.npm (npm download cache) | Yes — automatic mount | ~/.silo/cache/node/npm |
| yarn cache | Only if configured | See below |
| pnpm store | Only if configured | See below |
Three strategies for persisting deps. Pick based on what you’re doing:
Option 1: node_modules/ in the project (the default)
Because your project directory is mounted rw, npm install creates a real node_modules/ on your host disk. It persists. Nothing to configure.
# silo.toml — give npm network access
tools = ["node"]
[overrides.node.network]
hostAccess = true
[overrides.node.network.proxy]
allow = ["registry.npmjs.org", "*.npmjs.org", "*.github.com"]
npm install
npm run dev
This is the default workflow and covers 90% of cases. The catch: node_modules/ on macOS under APFS with hundreds of thousands of small files from a cross-VFS mount can be slower than native. In practice it’s fine for dev; if you’re on a massive monorepo and notice it, jump to option 2 or 3.
Excluding node_modules from the mount
If you’d rather not see node_modules/ on the host at all (faster I/O, no cross-VFS overhead), tell Silo to hide it:
[mount]
exclude = ["node_modules"]
Now node_modules/ lives entirely inside the VM and vanishes on exit — which means you need option 2 or 3 to make it persist.
Option 2: persist with silo build
silo build runs a command inside the VM and captures the resulting filesystem as a rootfs layer. Use it to bake npm install’s output into the image.
# silo.toml
tools = ["node"]
[mount]
exclude = ["node_modules"]
[overrides.node.network]
hostAccess = true
[overrides.node.network.proxy]
allow = ["registry.npmjs.org", "*.npmjs.org"]
silo build node npm install
After this, every silo run node for this project starts with /workspace/node_modules/ already populated — but because of the mount.exclude, it’s the VM’s node_modules, not your host’s. The host directory stays clean.
Future runs:
node -e "require('express')" # works, no npm install needed
Iterating:
silo build node --rerun # re-run stored npm install
silo build node --rerun --script "npm ci" # override the script
silo build node --remove # throw out the layer, back to stock
silo setupis kept as a deprecated alias ofsilo buildin 0.4.x. Both work;setupgoes away in 0.6.
Global installs
# Globally available — every project sees typescript
silo build --global node npm install -g typescript
# Project-local stacks on top of global
silo build node npm install
Lookup order: project rootfs → global build rootfs → rootfs cache → OCI unpack.
When each option wins
- Project
node_modules/on host — default; works with every editor, every CI. Start here. silo build+mount.exclude— reproducible builds, faster I/O for huge monorepos, hermetic CI images.- Global
silo build— tools you want everywhere:typescript,prettier,eslint,vercel,netlify,wrangler.
Using different Node versions
Install an extra version globally
silo install node@20 # replaces the global node
Silo refuses a second silo install node unless you --force. The install key is the tool name (node), so installing node@20 replaces the global definition.
Pin a version for one project (recommended)
cd ~/projects/legacy-frontend
silo use node@18
silo sync # install anything missing + warm cache
node --version # v18.x — only inside this project
Outside this project, the global version is in effect. Walk into a project with a different pin and you’ll get its version automatically — no .nvmrc-style rehash per shell.
silo use writes to silo.toml:
tools = ["node"]
[overrides.node]
image = "docker.io/library/node:18-slim"
Undo with silo unuse node.
Manual override
Edit silo.toml directly if you want a version tag not in the registry:
[overrides.node]
image = "docker.io/library/node:22-alpine"
Any image tag works. Silo doesn’t care whether it’s in the built-in registry — that list just drives the --available display and silo install <tool>@<version> shorthand.
Migrating from nvm / volta / fnm
| nvm / volta / fnm | Silo |
|---|---|
nvm install 20 / volta install node@20 | silo install node@20 |
nvm use 18 (shell) | walk into the project with silo.toml pinning 18 |
.nvmrc / volta.node in package.json | silo.toml with overrides.node.image |
nvm alias default 22 | silo install node@22 --force |
Same mental model, plus a real sandbox.
yarn, pnpm, bun
The default node tool only ships node, npm, npx shims. yarn and pnpm don’t exist out of the box. There are two good ways to get them.
Corepack (recommended)
Modern Node ships Corepack, which manages yarn and pnpm on demand. Enable it once in a silo build and both are available inside the VM:
silo build node sh -c "corepack enable && corepack prepare pnpm@latest --activate && corepack prepare yarn@stable --activate"
Then add host shims so you can type yarn and pnpm directly:
silo shim node add yarn
silo shim node add pnpm
Now:
pnpm install
pnpm run dev
yarn install
If your package.json has a packageManager field ("packageManager": "[email protected]"), corepack will pin to exactly that version automatically. No version drift between teammates.
Global install with npm
Simpler, less clever:
silo build --global node npm install -g yarn pnpm
silo shim node add yarn
silo shim node add pnpm
Works the same way. Corepack’s advantage is per-project pinning via packageManager; without that, global npm is fine.
bun
Bun isn’t in the default registry, but it’s one silo install away:
silo install bun --image oven/bun:latest --shim bun,bunx --network
Or add it to ~/.silo/registry.yaml so it lives alongside the built-ins.
Custom command mappings
silo shim supports host_name:container_command if the shim name differs from the binary:
silo shim node add npm2:npm # `npm2` on host runs `npm` in container
silo shim node add pnpx:pnpm dlx # `pnpx foo` runs `pnpm dlx foo`
Useful for avoiding conflicts when you temporarily want two Node installs side by side.
Networking for npm / yarn / pnpm
All three need outbound access. The allowlist is the same:
[overrides.node.network]
hostAccess = true
[overrides.node.network.proxy]
allow = [
"registry.npmjs.org",
"*.npmjs.org",
"*.github.com", # many packages download binaries from GitHub releases
"*.cloudfront.net", # electron, prebuilt binaries
]
If you use a private registry, add it:
allow = [
# …
"npm.mycompany.com",
"npm.pkg.github.com",
]
For yarn/pnpm specifically you might also need:
allow = [
# …
"yarnpkg.com",
"registry.yarnpkg.com",
"*.pnpm.io",
]
The proxy is allowlist-first: everything not in allow is blocked. That’s the whole point — a compromised postinstall script can’t phone home to attacker.example.
Passing .npmrc
Private registry auth usually lives in ~/.npmrc or ./.npmrc. Mount it read-only:
passFiles = [".npmrc"]
Or pass the auth token as env:
passEnv = ["NPM_TOKEN", "GITHUB_TOKEN"]
Resource limits — why your dev server gets Killed
The default node tool boots with 512 MB of guest RAM. That’s plenty for npm install but a Vite/Next/Vue dev build (esbuild + react-refresh + your source graph) blows past it almost instantly. The kernel inside the VM OOM-kills the process and you see a bare Killed message with no stack trace.
Bump the cap per project:
[overrides.node]
cpus = 4
memoryMB = 6144 # 6 GB is comfortable for most dev servers
rootfsSizeMB = 4096 # bigger root for monorepos with many deps
env = { NODE_OPTIONS = "--max-old-space-size=5120" } # tell V8 the heap can grow
memoryMB is the hard guest-RAM cap (the kernel kills above it). NODE_OPTIONS=--max-old-space-size tells Node’s V8 how much of that RAM it’s allowed to use for the JS heap — set it ~80% of memoryMB so spawned helpers (esbuild workers, swc) still have room.
Verify with silo current node — it prints the effective definition after silo.toml overrides are applied.
Forwarding dev-server ports
Vite, Next, webpack-dev-server, etc. — map the ports:
[[overrides.node.ports]]
host = 3000
guest = 3000
[[overrides.node.ports]]
host = 5173 # Vite HMR
guest = 5173
Shorthand: silo config ports add node 3000:3000. Ports imply hostAccess = true.
Recipes
Next.js app
# silo.toml
tools = ["node"]
passEnv = ["DATABASE_URL", "NEXTAUTH_SECRET"]
[mount]
exclude = ["node_modules", ".next"]
[overrides.node]
image = "docker.io/library/node:20-slim"
cpus = 4
memoryMB = 6144 # Next.js dev/build needs > 2 GB; default is 512 MB
rootfsSizeMB = 4096
env = { NODE_OPTIONS = "--max-old-space-size=5120" }
[overrides.node.network]
hostAccess = true
[overrides.node.network.proxy]
allow = ["registry.npmjs.org", "*.npmjs.org", "*.vercel.com"]
[[overrides.node.ports]]
host = 3000
guest = 3000
silo build node npm install
npm run dev
Vite + React with pnpm
silo build node sh -c "corepack enable && corepack prepare pnpm@latest --activate"
silo shim node add pnpm
# silo.toml
tools = ["node"]
[mount]
exclude = ["node_modules"]
[overrides.node]
cpus = 4
memoryMB = 6144 # Vite + esbuild + react-refresh — 512 MB OOMs
[overrides.node.network]
hostAccess = true
[overrides.node.network.proxy]
allow = ["registry.npmjs.org", "*.npmjs.org", "*.github.com"]
[[overrides.node.ports]]
host = 5173
guest = 5173
silo build node pnpm install
pnpm dev
Playwright browser tests
Silo ships a dedicated playwright tool that includes the browser binaries:
silo install playwright
silo build playwright npx playwright install --with-deps
npx playwright test
This avoids downloading ~400 MB of browser binaries into your project on every install.
Common pitfalls
- “Cannot find module” after restart — you ran
npm installin onesilo runand expected it to survive. Either putnode_modulesin the host-mounted project, or usesilo build node npm install. - Vite HMR not reloading — Vite uses a filesystem watcher; container inotify needs
--hoston the dev server and the port forwarded. Make surenetwork.hostAccess = trueand the port is in theportslist. npm installhangs — network probably isn’t allowlisted. Checkregistry.npmjs.orgis inproxy.allow.- Corepack says “not enabled” — you need to rerun
corepack enableinside the VM; it modifies a few scripts under/usr/local/bin, and those modifications need to be captured bysilo build.
Where to go next
- Python with Silo — same patterns for Python.
- Understanding silo.toml — where this config file lives, how it merges, and every field that matters.
- How Silo works — what’s actually happening under each
silo run.