# How Do I Set Up a Node.js Dev Environment in a Cloud Sandbox With pnpm?

> A new sandbox has Node 22.23.3 and pnpm 12.14, and pnpm switches itself to the version in your packageManager field. For a newer Node, install fnm and run fnm install 24. Clone your repo, run pnpm install --frozen-lockfile, then save a named snapshot so the next sandbox skips the 11.7-second cold install.

Source: https://sandboxer.com/guides/node-dev-environment-in-a-cloud-sandbox. Updated 2026-10-27.

**Short answer:** A new sandbox has Node 22.23.3 and pnpm 12.14, and pnpm switches itself to the version in your packageManager field. For a newer Node, install fnm and run fnm install 24. Clone your repo, run pnpm install --frozen-lockfile, then save a named snapshot so the next sandbox skips the 11.7-second cold install.

We ran every command on this page on prod on 2026-10-27, on `small` sandboxes (2 vCPU, 4 GB) in eu-central. The project is [antfu-collective/vitesse-lite](https://github.com/antfu-collective/vitesse-lite) at commit `0b35297`: a Vite + Vue starter with a `pnpm-lock.yaml`, about 620 packages, Vitest tests and ESLint. This is the Node twin of the [Python starter](/guides/python-dev-environment-in-a-cloud-sandbox); editor setup over SSH is covered there.

## Results

| Step | Time on `small` |
| - | - |
| `fnm install 24` (Node 24.22.1) | 2.6 s |
| `pnpm install --frozen-lockfile`, empty store and cache (3 runs) | 11.75, **11.72**, 11.50 s |
| Same, warm store, no `node_modules` (3 runs) | 0.73, **0.70**, 0.70 s |
| Same, nothing to do (3 runs) | **0.06 s** |
| `pnpm test --run` (Vitest) | 3 passed, 3.3 s wall |
| `sbx snapshot` to a named snapshot | 2.1 s |
| SDK `create({ from })` to ready | 2.7 s |
| In the new sandbox: install as saved / store only (3 runs each) | **0.14 s** / **1.03 s** |

The install timings above ran on the system Node 22.23.3. The runs in the new sandbox used Node 24 from fnm. Unlike uv on the Python page, the cold pnpm install here is the slow step. The snapshot removes it.

## 1. See what the sandbox already has

```bash
sbx new --name nodedev-1 --type small
sbx exec sb_clx3ut4cn8 bash -lc "node --version; which node npm corepack pnpm; npm --version; corepack --version; cd /tmp && pnpm --version"
```

```text
v22.23.3
/usr/bin/node
/usr/bin/npm
/usr/bin/corepack
/usr/bin/pnpm
10.9.9
0.36.0
12.14.0
```

Node is the NodeSource package `22.23.3-1nodesource1`. `/usr/bin/pnpm` is a global npm install of pnpm, not a Corepack shim. These are the versions on 2026-10-27; the image changes over time. The full inventory is in [What's pre-installed in a sandbox](/guides/whats-preinstalled-in-a-sandbox).

## 2. When the image's Node is too old

Some packages require a newer patch release than the image has. Astro 7.4.2 declares `"node": ">=22.23.4"` in its `engines` field and refuses to start:

```bash
sbx exec sb_clx3ut4cn8 bash -lc "cd /tmp && pnpm dlx astro@7.4 --version"
```

```text
+ astro 7.4.2

Node.js v22.23.3 is not supported by Astro!
Please upgrade Node.js to a supported version: ">=22.23.4"
```

You have three ways to get another Node. We tested fnm.

| Method | What it does | Needs sudo | Tested here |
| - | - | - | - |
| [fnm](https://github.com/Schniz/fnm) | Installs any Node version under `~/.local/share/fnm` and switches per shell | No | Yes |
| [NodeSource](https://github.com/nodesource/distributions) | Replaces the system `nodejs` apt package with another major line | Yes (`sudo` needs no password) | No |
| Corepack | Installs package managers (pnpm, Yarn), not Node | No | Yes, on Node 24 |

Install fnm and Node 24, then make it the default for login shells:

```bash
sbx exec sb_clx3ut4cn8 --timeout 300 bash -lc '
  curl -fsSL https://fnm.vercel.app/install | bash -s -- --skip-shell
  export PATH=~/.local/share/fnm:$PATH
  fnm install 24
  cat >> ~/.profile <<"P"
export PATH="$HOME/.local/share/fnm:$PATH"
eval "$(fnm env --shell bash)"
P'
sbx exec sb_clx3ut4cn8 bash -lc "fnm default 24 && fnm ls && node --version && pnpm dlx astro@7.4 --version"
```

```text
Installing Node v24.22.1 (x64)
…
* v24.22.1 default
* system
v24.22.1
   astro  v7.4.2
```

fnm took 2.3 seconds to install and Node 24 another 2.6 seconds. Put the `fnm env` lines in `~/.profile`, not `~/.bashrc`: Ubuntu's `.bashrc` returns early for non-interactive shells. With them in `~/.profile`, `sbx exec … bash -lc`, detached commands and the SDK's `exec` all ran Node 24 in our test. Everything lives under `/home/user`, so it survives `sbx stop` and goes into a snapshot. `fnm ls-remote` also listed v22.23.4 if you want to stay on the 22 line.

## 3. pnpm and Corepack

vitesse-lite's `package.json` pins `"packageManager": "pnpm@12.3.4"`. You don't need Corepack to honor it. The image's pnpm 12.14 downloaded 12.3.4 and ran it inside the project:

```bash
sbx exec sb_clx3ut4cn8 bash -lc "git clone -q https://github.com/antfu-collective/vitesse-lite ~/app && cd ~/app && git checkout -q 0b352977755e && pnpm --version"
```

```text
12.3.4
```

That is pnpm's [`managePackageManagerVersions`](https://pnpm.io/settings#managepackagemanagerversions) setting. If your team standardizes on Corepack, or the project uses Yarn, enable it. fnm's Node directory is in your home, so no `sudo` is needed:

```bash
sbx exec sb_clx3ut4cn8 bash -lc "corepack --version && corepack enable pnpm && which pnpm && cd ~/app && pnpm --version"
```

```text
0.36.0
/home/user/.local/state/fnm_multishells/2687_1793098779889/bin/pnpm
12.3.4
! Corepack is about to download https://registry.npmjs.org/pnpm/-/pnpm-12.3.4.tgz
…
```

The Corepack notice goes to stderr, so it can print after the version.

Corepack caches package managers in `~/.cache/node/corepack` (109 MB in our sandbox, after a couple of versions).

## 4. Install: cold store, warm store, nothing to do

We timed three cases, three runs each, with this script in the sandbox. "Cold" deletes `node_modules`, the store (`~/.local/share/pnpm/store/v11`) and the metadata cache (`~/.cache/pnpm/v11`). "Warm" deletes only `node_modules` and adds `--offline` to prove the network isn't used.

```bash
cd ~/app
TIMEFORMAT=%R
for i in 1 2 3; do rm -rf node_modules ~/.local/share/pnpm/store/v11 ~/.cache/pnpm/v11 ~/.cache/pnpm/lockfile-verified.jsonl; echo -n "cold $i: "; { time pnpm install --frozen-lockfile >/dev/null 2>&1 ; } 2>&1; done
for i in 1 2 3; do rm -rf node_modules; echo -n "warm $i: "; { time pnpm install --frozen-lockfile --offline >/dev/null 2>&1 ; } 2>&1; done
for i in 1 2 3; do echo -n "noop $i: "; { time pnpm install --frozen-lockfile >/dev/null 2>&1 ; } 2>&1; done
```

```text
cold 1: 11.745
cold 2: 11.499
cold 3: 11.716
warm 1: 0.729
warm 2: 0.696
warm 3: 0.697
noop 1: 0.062
noop 2: 0.067
noop 3: 0.064
```

`--frozen-lockfile` installs exactly what `pnpm-lock.yaml` pins and fails rather than updating it. Without `--offline` the warm runs took 0.72 to 0.82 seconds.

On disk, `node_modules` was 293 MB and the store 281 MB, but `du` counted only 302 MB for both together. pnpm hardlinks files from the store into `node_modules`, so keeping both in a snapshot costs about the same as one. The metadata cache was larger than either, 427 MB, almost all of it in `metadata-full`.

## 5. Run the tests and lint

```bash
sbx exec sb_clx3ut4cn8 --timeout 300 bash -lc "cd ~/app && export NO_COLOR=1 && pnpm test --run && pnpm lint"
```

```text
 Test Files  2 passed (2)
      Tests  3 passed (3)
…
✖ 2 problems (0 errors, 2 warnings)
```

`pnpm test` runs `vitest`, which watches by default; `--run` makes it exit. The tests took 3.3 seconds wall time and lint 5.9 seconds.

## 6. A dev server on a private URL

Start Vite on `127.0.0.1` in the background, then host the port with `--localhost`:

```bash
sbx exec sb_clx3ut4cn8 --cwd app --detach "pnpm exec vite --host 127.0.0.1 --port 5173 --strictPort > ~/vite.log 2>&1"
sbx host sb_clx3ut4cn8 5173 --localhost --title "Vite dev"
```

```text
https://first-upbeat-honey-5173.box.sandboxer.com?_token=<token>
```

Without the token the URL answered `401`. With it, Sandboxer accepted the request, and Vite itself answered `403`:

```text
Blocked request. This host ("first-upbeat-honey-5173.box.sandboxer.com") is not allowed.
To allow this host, add "first-upbeat-honey-5173.box.sandboxer.com" to `server.allowedHosts` in vite.config.js.
```

Vite's dev server only answers hostnames it knows ([`server.allowedHosts`](https://vite.dev/config/server-options#server-allowedhosts)). A leading dot allows a domain and all its subdomains, so one line covers every sandbox and port:

```typescript
export default defineConfig({
  // …
  server: { allowedHosts: ['.box.sandboxer.com'] },
})
```

Vite restarted on its own when the config changed. Then the token link answered `302` and set a cookie, and the page loaded with `200` in about 0.2 seconds. We also opened Vite's HMR WebSocket through the hosted URL with that cookie. It answered `101 Switching Protocols` and pushed a `file-changed` event when we edited `src/components/TheCounter.vue` in the sandbox. We tested the socket with `curl`, not a browser.

A `--detach` process does not come back if the sandbox ever boots from disk. To keep a dev server running across that, use a systemd unit as shown in [Run a Next.js dev server with a preview URL](/guides/run-a-next-js-dev-server-with-a-preview-url).

## 7. Save it as a named snapshot

Stop the dev server first. A snapshot keeps files, not processes:

```bash
sbx exec sb_clx3ut4cn8 --kill 2929
sbx snapshot sb_clx3ut4cn8 nodedev-pnpm
```

```text
{"ok":true,"type":"snapshot.named.info","snapshot":{…,"name":"nodedev-pnpm",…,"status":"ready","type":"small",…}}
```

The save took 2.1 seconds. It holds Node 24, Corepack's pnpm, the clone, `node_modules`, the store and the metadata cache. You can delete the source sandbox afterwards. To name the snapshot after a hash of `pnpm-lock.yaml` and rebuild it when the lockfile changes, follow [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot).

## 8. Use the TypeScript SDK from outside and inside

**Outside**, on your laptop or CI runner, `new Sandboxer()` reads `SANDBOXER_API_KEY`. This script starts a sandbox from the snapshot, checks the toolchain, times the install and the tests, and deletes the sandbox:

```typescript
// from-snapshot.mjs, run on your laptop or CI runner
import { Sandboxer } from "sandboxer-sdk";

const sandboxer = new Sandboxer(); // reads SANDBOXER_API_KEY
const t = performance.now();
const sb = await sandboxer.create({ type: "small", from: "nodedev-pnpm", name: "nodedev-2" });
console.log(`${sb.id} ready in ${((performance.now() - t) / 1000).toFixed(1)} s`);

const time = (cmd) => `TIMEFORMAT=%R; { time ( ${cmd} ) ; } 2>&1 | tail -2`;
try {
  const steps = [
    ["toolchain", "echo node $(node --version) pnpm $(pnpm --version)"],
    ...Array(3).fill(["as saved", "pnpm install --frozen-lockfile >/dev/null"]),
    ...Array(3).fill(["store only", "rm -rf node_modules && pnpm install --frozen-lockfile >/dev/null"]),
    ["tests", "NO_COLOR=1 pnpm test --run 2>/dev/null | grep 'Tests '"],
  ];
  for (const [label, cmd] of steps) {
    const run = await sb.exec(["bash", "-lc", time(cmd)], { cwd: "app", timeoutSeconds: 300 });
    console.log(`${label}: exit ${run.exitCode}, ${run.stdout.trim().split("\n").join(" | ")}`);
  }
} finally {
  await sb.delete();
  console.log("deleted", sb.id);
}
```

```bash
npm i sandboxer-sdk
node from-snapshot.mjs
```

```text
sb_lqsmaswx4k ready in 2.7 s
toolchain: exit 0, node v24.22.1 pnpm 12.3.4 | 0.168
as saved: exit 0, 0.262
as saved: exit 0, 0.139
as saved: exit 0, 0.115
store only: exit 0, 1.638
store only: exit 0, 1.034
store only: exit 0, 0.926
tests: exit 0, Tests  3 passed (3) | 3.458
deleted sb_lqsmaswx4k
```

Node 24 and the pinned pnpm came with the snapshot; nothing was downloaded. The first command after the restore was slower each time we ran this script (we ran it four times). The "store only" case shows why you keep `node_modules` in the snapshot, not just the store: a relink still took about one second.

**Inside** the sandbox, `SANDBOXER_TOKEN` and `SANDBOXER_API_URL` are already set, so `new Sandboxer()` needs no arguments. That token is limited to its own sandbox:

```typescript
// whoami.mjs, run in the sandbox
import { Sandboxer, SandboxerError } from "sandboxer-sdk";

const sandboxer = new Sandboxer(); // reads SANDBOXER_TOKEN and SANDBOXER_API_URL
const me = await sandboxer.get(process.env.SANDBOXER_ID);
console.log(me.id, me.info.name, me.state);

const run = await me.exec("node --version && pnpm --version", { cwd: "app" });
console.log("exec:", run.stdout.trim().split("\n").join(" "));

let n = 0;
for await (const _ of sandboxer.list()) n++;
console.log(n, "sandbox(es) visible to this token");

try {
  await sandboxer.create({ type: "small" });
} catch (e) {
  if (e instanceof SandboxerError) console.log(e.status, e.code, e.message);
  else throw e;
}
```

```bash
mkdir ~/sdk-check && cd ~/sdk-check && pnpm add sandboxer-sdk && node whoami.mjs
```

```text
sb_clx3ut4cn8 nodedev-1 ready
exec: v24.22.1 12.3.4
1 sandbox(es) visible to this token
403 api_key_action_forbidden This API key does not allow sandbox.create.
```

`pnpm add` took 0.8 seconds; the SDK has no runtime dependencies. Keep it in its own folder so it doesn't change your project's lockfile. The name is on `me.info`: the `Sandbox` object has no `name` property of its own.

| | Outside (laptop, CI) | Inside the sandbox |
| - | - | - |
| Key | Your `SANDBOXER_API_KEY` | `SANDBOXER_TOKEN`, set for you |
| Can create, fork, snapshot, delete | Yes, as your key's scope allows | No (403) |
| Can exec, read and write files, host ports | Any sandbox your key reaches | Only its own sandbox |
| Use it for | Orchestrating sandboxes, CI | Code in the sandbox that needs its own id, files or hosted URL |

## FAQ

### Which Node.js version does a Sandboxer sandbox have?

On 2026-10-27, Node 22.23.3 from NodeSource, with npm 10.9.9, Corepack 0.36.0 and pnpm 12.14.0. The image is updated over time, so check `node --version` in your own sandbox.

### Do I need Corepack to use the pnpm version my project pins?

No. In our test the preinstalled pnpm read `packageManager` and ran the pinned 12.3.4 itself. Use Corepack if your team already does or the project uses Yarn.

### Why does my Vite dev server say "Blocked request. This host is not allowed"?

Vite rejects hostnames that aren't in `server.allowedHosts`. Add `'.box.sandboxer.com'` to it; Vite restarts by itself when the config changes.

### Does a Node version installed with fnm survive sbx stop?

Yes. fnm keeps Node under `/home/user/.local/share/fnm`, which is on the sandbox disk. Stop keeps the disk, and a named snapshot or fork copies it.

### Can code inside the sandbox start more sandboxes with the SDK?

Not with the built-in `SANDBOXER_TOKEN`. It returned 403 `api_key_action_forbidden` for `create` in our test. Orchestrate from outside with your own API key.

## Related

- [Set up a Python dev environment with uv](/guides/python-dev-environment-in-a-cloud-sandbox)
- [Cache dependencies in a named snapshot](/guides/cache-dependencies-in-a-named-snapshot)
- [Run a Next.js dev server with a preview URL](/guides/run-a-next-js-dev-server-with-a-preview-url)
- [TypeScript SDK](/docs/sdk-typescript)
