Node.js

Node, npm, npx and yarn under boks — from boks node script.js to a project where npm install is just npm install.

Read the three levels first if you haven't.

Tools and their defaults

ToolCapabilitiesWhy
noderwA runtime. Writes the working tree; reaches nothing.
npmrw, netA package manager. Installing means talking to a registry.
npxrwExecutes an already-resolved package. Running arbitrary code that can silently phone home is a different proposition from installing something you named.
yarnrw, netPackage manager, same as npm.

npm and npx ship in the same image and share a version — the capability difference is a per-tool-name entry in the index's policy, because one image label cannot say network=true for one name and false for the other.

Known wart: boks --info npm and boks --info pip display CAPS rw and omit the net they really run with, because that view reads the shared image label rather than the per-name policy override. The grant is real — every npm install works without --cap net.

npm's image contains no Node at all. Its index entry declares requires_compose = ["node"], so a node runtime is composed into the container at run time — which is also how npm run <script> can shell out to node and have it resolve. Override which node with boks -e node@22 npm ….

Two ways to have dependencies

This is the decision that shapes everything else.

A. An environment image — -e @npm:package.json

boks builds a layered image with the dependencies installed into it, cached by content hash. Nothing lands on your disk.

boks -e @npm:package.json node app.js
  • Nothing to clean up, nothing to .gitignore, no node_modules to go stale.
  • The image is scanned for vulnerabilities before anything runs.
  • Unchanged package.json means no rebuild.
  • Reproducible: the resolved tree is recorded in .boks-env.lock and is part of the environment's identity, so the same project builds the same environment on any machine.

Both module systems work. The module tree is mounted read-only at node_modules inside the container, which is where Node's own resolvers look — require() and import alike, and node_modules/.bin lands where npm run expects it:

$ boks -e @npm node app.mjs      # ESM
$ boks -e @npm node app.cjs      # CommonJS

Nothing is written to your project except .boks-env.lock. The mount leaves an empty node_modules/ directory behind as its mountpoint — gitignore it; there is nothing in it.

B. A real node_modulesboks npm install

Without -e, npm runs in a container and writes into your working tree, the way every Node tutorial assumes:

boks npm install
boks node app.js          # no -e needed; node finds node_modules itself
  • Every upstream instruction works verbatim, including npm ci and npm run build.
  • Editors and language servers on the host can see the dependencies.

This is the escape hatch, not the destination. It puts a large, stateful, gitignored tree in your project that nothing keeps in step with package.json — update the manifest and it is on you to remember to reinstall. That is the same class of chore as remembering to run apt upgrade, and it is what model A exists to remove.

The one thing it still buys is host-side editor tooling: a language server running outside the container cannot see into an image. If that matters more to you than the sandbox owning your dependencies, this is why.

Model A wins when both are present

With an environment image in play, its module tree is mounted over the working-tree one, so the manifest stays the source of truth and a stale node_modules cannot silently shadow what you meant to run:

$ boks -e @npm node -e "console.log(require('cowsay/package.json').version)"
1.5.0        # the environment's, not whatever is lying on disk

Prefer model A. Use model B only for the editor-tooling case above.

Level 1 — on the command line

# stdlib only, no dependencies
boks node script.js

# dependencies from package.json, built into an environment image
boks -e @npm:package.json node app.js

# dependencies into the working tree
boks npm install
boks npm run build
boks npm test

# run a package binary without installing it globally
boks --cap net npx create-vite my-app

Serving

A server needs two things, and forgetting either is the most common mistake in this whole guide:

boks -p 3000 node server.js --host 0.0.0.0
  1. -p 3000 publishes the port (and implies network — no --cap net needed).
  2. The server must bind 0.0.0.0. A server on 127.0.0.1 inside a container is bound to the container's loopback and is unreachable from your machine:
$ boks -p 3000 node server.js       # binds 127.0.0.1 by default
$ curl http://127.0.0.1:3000
curl: (52) Empty reply from server

Exit 52 means the port forward worked and nothing was listening on a reachable address. Exit 7 means no port was published at all. The two error codes tell you which half you got wrong.

Most framework CLIs have their own flag for this: --host 0.0.0.0 (Vite, Vue, Svelte), -H 0.0.0.0 (Next.js), --host 0.0.0.0 (Angular). Some read HOST/HOSTNAME from the environment instead, which .boksrc's set_env can supply.

Level 2 — a project .boksrc

tools:
  node:
    # Resolves dependencies from package.json. Omit only if you have
    # deliberately chosen model B and want the working tree's node_modules.
    env_file: package.json

    # This project's subcommand is at args[1]: for `node server.js serve`,
    # args[0] is the literal string "server.js".
    subcommand_index: 1
    subcommands:
      serve:
        ports: ["3000"]
        args: ["--host", "0.0.0.0"]

boks node server.js serve now publishes a port and binds correctly, while boks node server.js — same tool, same file, no serve — gets neither, because it never asked to be a server. That narrowness is the point: capabilities exist only for the invocation that needs them.

For projects driven through npm scripts, key the subcommands on npm's own arguments instead — and mind which index the script name lands on:

tools:
  npm:
    subcommand_index: 1        # `npm run dev` → args[0]="run", args[1]="dev"
    subcommands:
      dev:
        ports: ["5173"]
      preview:
        ports: ["4173"]
tools:
  npm:
    # No subcommand_index: `npm start` and `npm test` are npm's OWN
    # subcommands, so the word you want is already at args[0].
    subcommands:
      start:
        ports: ["4200"]

Getting this wrong is silent — a key that never matches is inert, exactly as if the entry weren't there — so if a port isn't being published, check which index the word you keyed on actually sits at.

dev/prod variants

  node-dev:
    alias: node
    subcommand_index: 1
    set_env: { NODE_ENV: development }
    subcommands:
      serve: { ports: ["3000"], args: ["--host", "0.0.0.0"] }

  node-prod:
    alias: node
    subcommand_index: 1
    set_env: { NODE_ENV: production }
    subcommands:
      serve: { ports: ["8080:3000"], args: ["--host", "0.0.0.0"] }

Different host ports, so both run at once:

boks node-dev server.js serve      # http://127.0.0.1:3000, NODE_ENV=development
boks node-prod server.js serve     # http://127.0.0.1:8080, NODE_ENV=production

What the review will show you

env_file is the field worth reading carefully at the trust prompt: some packages auto-grant capabilities. express and @nestjs/core both grant net, so a project installing either shows

▍               node: -e package.json, +net (via package.json)

even though the overlay's own capabilities field says nothing.

Level 3 — shims

boks -i node
boks -i npm
boks -i npx
$ npm install

added 41 packages, and audited 42 packages in 1s
found 0 vulnerabilities

$ npm start
$ node app.js

No Node on the host, no boks on any line. This is the level where a framework's own README works unedited.

Model A is the point of level 3. In shim mode there is nowhere to type a boks flag, so .boksrc is the only thing that can say what the project needs — and env_file is what makes node app.js resolve dependencies at all. Keep it, alongside the ports, capabilities and env vars only .boksrc can supply here.

Corrected 2026-08-11. This section previously read "prefer model B at level 3," on the grounds that a working-tree node_modules shadowed the environment image anyway. That advice existed to work around a real defect: -e @npm installed to a path Node's ESM resolver never consults, so import failed while require worked, and every ESM project — which is to say every modern framework — appeared to need a working-tree install. The defect is fixed; the advice it produced is not merely obsolete but backwards.

The sandbox is still there

$ node -e "fetch('https://registry.npmjs.org/cowsay').then(r=>console.log(r.status)).catch(e=>console.log('failed:',e.cause?.code))"
failed: EAI_AGAIN

EAI_AGAIN is DNS failing inside a container with no network — from the same directory, seconds after npm install talked to that exact host. Your installer reaches the registry; your application reaches nothing. A malicious postinstall script gets the installer's capabilities for the length of the install, and your app still runs with none of them.

Caveats specific to Node

  • boks -i node overwrites ~/.local/bin/node if something is there — nvm, fnm, Volta and Homebrew all like that name.
  • Version managers stop applying. node is now whatever the index resolves, pinned with node@22 or in boks.toml, not by .nvmrc.
  • Editor integrations that spawn node get the containerised one.

Frameworks

The pattern is the same for all of them: a dev server needs a published port and a 0.0.0.0 bind, and a framework CLI usually has its own flag for the second half.

Each row below is the overlay that project's own example actually ships:

FrameworkDev commandKeyed onPortSpliced args
Vitenpm run devdev @ index 15173-- --host 0.0.0.0
Vuenpm run devdev @ index 15173-- --host 0.0.0.0
Sveltenpm run devdev @ index 15173-- --host 0.0.0.0
Next.jsnpm run devdev @ index 13000-- --hostname 0.0.0.0
Angularnpm startstart @ index 04200-- --host 0.0.0.0
NestJSnest start (via --exec-as) / node dist/main.jsstart and dist/main.js, both @ index 03020:3000
Expressnode app.jsapp.js @ index 03000

So a Vite-family project's whole overlay is:

tools:
  npm:
    subcommand_index: 1
    subcommands:
      dev:
        ports: ["5173"]
        args: ["--", "--host", "0.0.0.0"]

The -- is npm's own convention for passing arguments through to the script it runs, rather than consuming them itself. args is a plain sequence splice — boks never inspects what a token means — so whatever the tool needs is literally what you write. Note also that Next.js wants --hostname where the Vite family wants --host: this is per-framework trivia the overlay exists to absorb once, so nobody has to hold it in their head.

The last two rows key on a filename rather than a verb, because the command really is node <file> and args[0] is that file. That works fine — the key is matched as a plain string against the argument, not parsed.

CI

Level 1, explicitly, every time:

- run: boks --non-interactive npm ci
- run: boks --non-interactive npm test

--non-interactive never applies an overlay on a project that isn't already trusted, so a pipeline's real capability grants stay visible in the pipeline.

Worked examples

  • node-simple — all three levels, ports and 0.0.0.0, dev/prod aliases. No dependencies.
  • node-package — all three levels, both dependency models, npm install as npm install.
  • npm-node-lifecycle — adding a package when you don't know its version, freezing the lockfile.
  • node-yarn — the same with yarn.