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:

  1. Running node / npm and what survives between invocations.
  2. Making dependencies persist — project node_modules vs. silo build.
  3. Running different Node versions globally or per project.
  4. 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 node --shim npm -- test
silo npx tsc --watch        # == silo run node --shim npx -- 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.

ThingPersists?Where
Project files (including node_modules/ if unignored)Yes — mounted rwHost disk
Global npm install -g targetNo by defaultEphemeral VM
~/.npm (npm download cache)Yes — automatic mount~/.silo/cache/node/npm
yarn cacheOnly if configuredSee below
pnpm storeOnly if configuredSee 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.

# .siloconf — give npm network access
tools: [node]
overrides:
  node:
    network:
      hostAccess: true
      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.

# .siloconf
tools: [node]
mount:
  exclude:
    - node_modules
overrides:
  node:
    network:
      hostAccess: true
      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 setup is kept as a deprecated alias of silo build in 0.4.x. Both work; setup goes away in 0.6.

Global installs

# Globally available — every project sees typescript
silo build node --global -- 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

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.

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 .siloconf:

tools: [node]
overrides:
  node:
    image: docker.io/library/node:18-slim

Undo with silo unuse node.

Manual override

Edit .siloconf 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 / fnmSilo
nvm install 20 / volta install node@20silo install node@20
nvm use 18 (shell)walk into the project with .siloconf pinning 18
.nvmrc / volta.node in package.json.siloconf with overrides.node.image
nvm alias default 22silo 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.

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": "pnpm@9.0.0"), corepack will pin to exactly that version automatically. No version drift between teammates.

Global install with npm

Simpler, less clever:

silo build node --global -- 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
      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:

          - npm.mycompany.com
          - npm.pkg.github.com

For yarn/pnpm specifically you might also need:

          - 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

Forwarding dev-server ports

Vite, Next, webpack-dev-server, etc. — map the ports:

overrides:
  node:
    ports:
      - host: 3000
        guest: 3000
      - host: 5173     # Vite HMR
        guest: 5173

Shorthand: silo config ports add node 3000:3000. Ports imply hostAccess: true.

Recipes

Next.js app

# .siloconf
tools: [node]
passEnv:
  - DATABASE_URL
  - NEXTAUTH_SECRET

mount:
  exclude:
    - node_modules
    - .next

overrides:
  node:
    image: docker.io/library/node:20-slim
    network:
      hostAccess: true
      proxy:
        allow:
          - registry.npmjs.org
          - "*.npmjs.org"
          - "*.vercel.com"
    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
# .siloconf
tools: [node]
mount:
  exclude:
    - node_modules
overrides:
  node:
    network:
      hostAccess: true
      proxy:
        allow:
          - registry.npmjs.org
          - "*.npmjs.org"
          - "*.github.com"
    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

Where to go next

← all posts