Freestyle Docs

Freestyle / Guides

How to Run Node.js in a Sandbox

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.

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

pnpm add freestyle
bun add freestyle
npm install freestyle
yarn add freestyle

Set your API key before calling the API:

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

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.

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:

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.

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.

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):

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.

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:

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.

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.

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:

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

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.
esc