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
| Tool | Capabilities | Why |
|---|---|---|
node | rw | A runtime. Writes the working tree; reaches nothing. |
npm | rw, net | A package manager. Installing means talking to a registry. |
npx | rw | Executes an already-resolved package. Running arbitrary code that can silently phone home is a different proposition from installing something you named. |
yarn | rw, net | Package 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 npmandboks --info pipdisplayCAPS rwand omit thenetthey really run with, because that view reads the shared image label rather than the per-name policy override. The grant is real — everynpm installworks 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, nonode_modulesto go stale. - The image is scanned for vulnerabilities before anything runs.
- Unchanged
package.jsonmeans no rebuild. - Reproducible: the resolved tree is recorded in
.boks-env.lockand 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_modules — boks 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 ciandnpm 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-appServing
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
-p 3000publishes the port (and implies network — no--cap netneeded).- The server must bind
0.0.0.0. A server on127.0.0.1inside 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=productionWhat 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_modulesshadowed the environment image anyway. That advice existed to work around a real defect:-e @npminstalled to a path Node's ESM resolver never consults, soimportfailed whilerequireworked, 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 nodeoverwrites~/.local/bin/nodeif something is there — nvm, fnm, Volta and Homebrew all like that name.- Version managers stop applying.
nodeis now whatever the index resolves, pinned withnode@22or inboks.toml, not by.nvmrc. - Editor integrations that spawn
nodeget 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:
| Framework | Dev command | Keyed on | Port | Spliced args |
|---|---|---|---|---|
| Vite | npm run dev | dev @ index 1 | 5173 | -- --host 0.0.0.0 |
| Vue | npm run dev | dev @ index 1 | 5173 | -- --host 0.0.0.0 |
| Svelte | npm run dev | dev @ index 1 | 5173 | -- --host 0.0.0.0 |
| Next.js | npm run dev | dev @ index 1 | 3000 | -- --hostname 0.0.0.0 |
| Angular | npm start | start @ index 0 | 4200 | -- --host 0.0.0.0 |
| NestJS | nest start (via --exec-as) / node dist/main.js | start and dist/main.js, both @ index 0 | 3020:3000 | — |
| Express | node app.js | app.js @ index 0 | 3000 | — |
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 and0.0.0.0, dev/prod aliases. No dependencies.node-package— all three levels, both dependency models,npm installasnpm install.npm-node-lifecycle— adding a package when you don't know its version, freezing the lockfile.node-yarn— the same with yarn.