---
title: "How to Run Node.js in a Sandbox"
description: "Build a reusable VM snapshot with the Node.js runtime, then run as many JavaScript snippets as you like on one long-lived sandbox VM."
url: "/docs/guides/run-nodejs-in-a-sandbox"
---

Build a snapshot with the Node.js runtime baked in, create one VM from it, then wrap it in a small `runNode()` helper that runs as many scripts as you like on that single sandbox.


## Install the SDK

```bash pnpm
pnpm add freestyle
```

```bash bun
bun add freestyle
```

```bash npm
npm install freestyle
```

```bash yarn
yarn add freestyle
```

Set your API key before calling the API:

```bash
export FREESTYLE_API_KEY="your-api-key"
```


## Build a Snapshot with the Node.js Runtime

The base image is minimal, so install Node.js explicitly. Use [nvm](https://github.com/nvm-sh/nvm) so the version is pinned and reproducible — install it into a fixed `NVM_DIR`, then install the Node version you want. The exec shell is a non-login `sh`, so set `NVM_DIR` and source `nvm.sh` in each command. Once the runtime is in place, snapshot the VM and delete the builder.

```ts
import { freestyle } from "freestyle";

const { vm: builder } = await freestyle.vms.create();

// Install nvm into a fixed directory, then install Node 22 and make it the default.
await builder.exec(
  "export HOME=/root NVM_DIR=/opt/nvm && mkdir -p $NVM_DIR && " +
    "curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.40.3/install.sh | bash",
);
await builder.exec(
  "export HOME=/root NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh && nvm install 22 && nvm alias default 22",
);

// Source nvm so the right node is on PATH for every command below.
const node = "export NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh &&";

// Confirm the runtime is present (nvm puts its node first on PATH).
console.log((await builder.exec(`${node} node -v`)).stdout?.trim()); // v22.22.3
console.log((await builder.exec(`${node} npm -v`)).stdout?.trim()); // 10.9.8

// Bake it into a reusable snapshot, then drop the builder.
const { snapshotId } = await builder.snapshot();
await builder.delete();
```

Every VM you create from `snapshotId` boots with Node.js ready, so you only pay the install cost once.


## A Reusable runNode() Utility

The VM is your reusable sandbox: create it once, then run as many snippets as you like on it. Wrap that in a helper that takes the `vm` as its first argument, writes the script with `vm.fs.writeTextFile`, and runs it synchronously with `vm.exec`. Because Node was installed with nvm, the run command reuses the same `node` prefix so the right `node` is on `PATH`. `vm.exec` blocks until the program exits and hands you `{ stdout, stderr, statusCode }`, so a single call captures the whole run.

```ts
async function runNode(vm, code: string) {
  const file = `/tmp/main-${crypto.randomUUID()}.mjs`;
  await vm.fs.writeTextFile(file, code);
  return await vm.exec(`${node} node ${file}`);
}
```

The helper never creates or deletes a VM — it just writes and runs. Each call writes to a unique `/tmp/main-<uuid>.mjs` path, so running many snippets on the same VM, sequentially or concurrently, can never overwrite each other's script.

Create one VM and reuse it across every call:

```ts
const { vm } = await freestyle.vms.create({ snapshotId });

const sumResult = await runNode(
  vm,
  `const nums = [1, 2, 3, 4, 5];
const sum = nums.reduce((a, b) => a + b, 0);
console.log(\`node \${process.version}\`);
console.log(\`sum=\${sum}\`);`,
);

console.log(sumResult.statusCode); // 0
console.log(sumResult.stdout); // node v22.22.3\nsum=15

// Same VM, another snippet — no new machine needed.
const upperResult = await runNode(
  vm,
  `console.log("hello".toUpperCase());`,
);

console.log(upperResult.stdout.trim()); // HELLO
```


## Pass Arguments and Throw on Failure

A raw `{ stdout, stderr, statusCode }` is easy to misuse — it is simple to forget to check `statusCode`. Make the helper safe by default: take the same reused `vm`, accept an `args` array forwarded to `process.argv`, throw when the script exits non-zero, and return just the captured streams. Like `runNode`, it never touches the VM lifecycle.

```ts
async function runNodeStrict(vm, code: string, args: string[] = []) {
  const file = `/tmp/main-${crypto.randomUUID()}.mjs`;
  await vm.fs.writeTextFile(file, code);
  const argv = args
    .map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
    .join(" ");
  const result = await vm.exec(`${node} node ${file} ${argv}`);
  if (result.statusCode !== 0) {
    throw new Error(
      `node exited with status ${result.statusCode}: ${result.stderr ?? ""}`,
    );
  }
  return { stdout: result.stdout ?? "", stderr: result.stderr ?? "" };
}
```

Pass the same `vm` you already created. The `args` are single-quoted before they reach the shell, so they arrive intact in `process.argv`.

```ts
const { stdout } = await runNodeStrict(
  vm,
  `const [a, b] = process.argv.slice(2).map(Number);
console.log(\`product=\${a * b}\`);`,
  ["6", "7"],
);
console.log(stdout); // product=42

// A failing script rejects instead of returning a bad result.
await runNodeStrict(vm, "process.exit(3);"); // throws: node exited with status 3
```


## Run a Server

The same snapshot that runs one-off scripts can also host a long-lived HTTP server. A Freestyle snapshot is a full memory and disk capture, so it preserves a *running* service: stand the server up under systemd on a fresh VM from `snapshotId`, then map a domain and reach it from the public internet.

Create a separate VM from the same snapshot (a distinct `server` binding, and capture its `vmId` for routing):

```ts
const { vm: server, vmId } = await freestyle.vms.create({
  snapshotId,
  idleTimeoutSeconds: null,
});
```

Write the server to `/srv`. It must bind `0.0.0.0` (not `127.0.0.1`) so traffic routed in from outside the VM can reach it.

```ts
await server.fs.writeTextFile(
  "/srv/server.js",
  `const http = require("http");
const httpServer = http.createServer((req, res) => {
  res.writeHead(200, { "Content-Type": "text/plain" });
  res.end("Hello from a Node.js server in a sandbox!\\n");
});
httpServer.listen(3000, "0.0.0.0", () => {
  console.log("listening on 0.0.0.0:3000");
});
`,
);
```

Run the server under **systemd** so it is supervised and restarts on crash. A systemd unit does not source your shell profile, so `ExecStart` must use the *absolute* `node` path, and any variables go through `Environment=`. Resolve that absolute path first:

```ts
const nodePath = (
  await server.exec(
    "export NVM_DIR=/opt/nvm && . $NVM_DIR/nvm.sh && command -v node",
  )
).stdout.trim(); // /opt/nvm/versions/node/v22.22.3/bin/node

await server.fs.writeTextFile(
  "/etc/systemd/system/nodeserver.service",
  `[Unit]
Description=Node HTTP server
After=network.target

[Service]
ExecStart=${nodePath} /srv/server.js
WorkingDirectory=/srv
Restart=always
Environment=HOME=/root

[Install]
WantedBy=multi-user.target
`,
);

await server.exec(
  "systemctl daemon-reload && systemctl enable --now nodeserver",
);
```

Wait until the server actually answers — poll for HTTP 200 on `localhost:3000` so you know it is listening before you route traffic to it.

```ts
let ready = false;
for (let i = 0; i < 30 && !ready; i++) {
  const probe = await server.exec(
    "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000 || true",
  );
  ready = probe.stdout?.trim() === "200";
  if (!ready) await new Promise((r) => setTimeout(r, 1000));
}
if (!ready) throw new Error("server did not become ready");
```

Map a domain to the VM's port. Pick your own unique subdomain under `style.dev` — it needs no DNS verification.

```ts
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`; // choose your own unique subdomain
await freestyle.domains.mappings.create({ domain, vmId, vmPort: 3000 });
```

Fetch it from outside the VM:

```ts
const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // Hello from a Node.js server in a sandbox!
```

Snapshot this VM too and every machine you create from it boots with the server already listening on port 3000 — no startup step, just map a domain and go.


## Stream the Server's Logs

`vm.exec()` buffers a command and only returns once it finishes, so it can't show a long-running service's output as it happens. To watch the logs live, open a [PTY](https://www.freestyle.sh/docs/vms/pty) on the VM — a real terminal streamed over a WebSocket (server-side only, Node 22+) — and follow the unit's journal. `onData` delivers the bytes as they arrive:

```ts
const session = await server.pty.open({
  cols: 120,
  rows: 30,
  onData: (bytes) => process.stdout.write(bytes), // live log lines
});

// Follow the service; new lines stream in until you detach.
session.write("journalctl -u nodeserver -f\n");

// session.detach() drops your handle — the service keeps running in the VM.
```
