Freestyle Docs

Freestyle / Guides

How to Run Bun in a Sandbox

Bake the Bun runtime into a VM snapshot, then reuse one sandbox VM to run as much TypeScript and JavaScript as you like.

This guide bakes the Bun JavaScript/TypeScript runtime into a reusable snapshot, then defines a small runBun() utility that takes a VM and runs a snippet on it. You boot one VM from the snapshot and reuse it across as many runs as you like, capturing each snippet’s output.

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 Bun Runtime

The base image is minimal, so install Bun explicitly. The exec shell is non-login with HOME unset, so set HOME and a fixed BUN_INSTALL dir, and install unzip since the installer needs it. Snapshot the VM once Bun is in place, then delete the builder.

import { freestyle } from "freestyle";

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

await builder.exec("apt-get update -qq && apt-get install -y -qq unzip");
await builder.exec(
  "export HOME=/root && export BUN_INSTALL=/opt/bun && curl -fsSL https://bun.sh/install | bash",
);

// Confirm the binary lives at a known absolute path.
const version = await builder.exec("/opt/bun/bin/bun --version");
console.log(version.stdout); // e.g. 1.3.14

const { snapshotId } = await builder.snapshot();
await builder.delete();

The /opt/bun install directory persists into every VM created from this snapshot, so you only pay the install cost once.

A Reusable runBun() Utility

With the snapshot built, wrap a single run in a helper that takes the VM as its first argument. It writes the code to a uniquely named file and runs it synchronously with vm.exec. The helper owns no VM lifecycle: you create the VM once and reuse it across every call. Bun is already installed, so call it by its absolute path.

async function runBun(vm, code: string) {
  const file = `/tmp/main-${crypto.randomUUID()}.ts`;
  await vm.fs.writeTextFile(file, code);
  return await vm.exec(
    `export HOME=/root && cd /tmp && /opt/bun/bin/bun run ${file}`,
  );
}

Each call writes to a unique /tmp/main-<uuid>.ts path via crypto.randomUUID(), so many runs on the same VM — sequential or concurrent — can never overwrite each other’s code. Each call returns { stdout, stderr, statusCode }, where statusCode of 0 means success.

Boot one VM from the snapshot, then run as many snippets as you like on it before deleting it once:

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

const first = await runBun(
  vm,
  `const greet = (name: string): string => \`Hello, \${name}!\`;
console.log(greet("Bun"));
console.log("Bun version:", Bun.version);`,
);
console.log(first.statusCode); // 0
console.log(first.stdout);
// Hello, Bun!
// Bun version: 1.3.14

// The same VM happily runs another, unrelated snippet.
const second = await runBun(vm, `console.log(2 + 40);`);
console.log(second.stdout); // 42

The VM is the reusable sandbox: create it once and run any number of snippets on it. The unique filename is what keeps those runs from clashing.

Do a Bit More: Typed Errors and Ad-Hoc Dependencies

The bare helper returns a status code you have to check by hand, and it can only run code that uses Bun’s built-ins. A second helper, runBunStrict, adds two things that make it genuinely useful: it throws on a non-zero exit so failures surface as exceptions, and it accepts an install option that bun adds npm packages before the run. Like runBun, it never touches the VM lifecycle, so it drops onto the same VM you already booted.

async function runBunStrict(vm, code: string, { install = [] }: { install?: string[] } = {}) {
  const file = `/tmp/main-${crypto.randomUUID()}.ts`;
  await vm.fs.writeTextFile(file, code);

  if (install.length > 0) {
    const add = await vm.exec(
      `export HOME=/root && cd /tmp && /opt/bun/bin/bun add ${install.join(" ")}`,
    );
    if (add.statusCode !== 0) throw new Error(`bun add failed: ${add.stderr}`);
  }

  const r = await vm.exec(
    `export HOME=/root && cd /tmp && /opt/bun/bin/bun run ${file}`,
  );
  if (r.statusCode !== 0) {
    throw new Error(`runBunStrict exited with ${r.statusCode}: ${r.stderr ?? r.stdout}`);
  }
  return r;
}

A snippet can pull in a dependency for the run, and a crash throws instead of returning a bad status code:

// Reuse the same `vm` from before — no need to create another.
const result = await runBunStrict(
  vm,
  `import { nanoid } from "nanoid";
console.log("id length:", nanoid().length);`,
  { install: ["nanoid"] },
);
console.log(result.stdout); // id length: 21

// A non-zero exit now raises an error you can catch.
try {
  await runBunStrict(vm, `process.exit(3);`);
} catch (err) {
  console.error(err.message); // runBunStrict exited with 3: ...
}

The dependency is installed into the running VM, so later snippets on it can use it too, while the snapshot itself never changes.

Run a Server

The same runtime snapshot is enough to host a long-lived HTTP server. Boot a fresh VM from it, write the server source and a systemd unit, and let systemd keep the process up. Reuse the snapshotId you built above — Bun already lives at /opt/bun/bin/bun.

systemd units do not source ~/.bashrc, so the unit sets HOME with Environment= and calls Bun by its absolute path. The server binds 0.0.0.0 so traffic from outside the VM can reach it.

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

const source = `Bun.serve({
  port: 3000,
  hostname: "0.0.0.0",
  fetch(req) {
    const url = new URL(req.url);
    return new Response(
      JSON.stringify({ message: "Hello from Bun on Freestyle!", path: url.pathname }),
      { headers: { "content-type": "application/json" } },
    );
  },
});
`;

const unit = `[Unit]
Description=Bun HTTP server
After=network.target

[Service]
Type=simple
Environment=HOME=/root
WorkingDirectory=/srv
ExecStart=/opt/bun/bin/bun /srv/server.ts
Restart=always

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

await server.exec("mkdir -p /srv");
await server.fs.writeTextFile("/srv/server.ts", source);
await server.fs.writeTextFile("/etc/systemd/system/bun-server.service", unit);

Enable the service, then poll until it actually answers on localhost so you only route traffic to a server that is really up.

await server.exec("systemctl daemon-reload && systemctl enable --now bun-server");

let ready = false;
for (let i = 0; i < 30; i++) {
  const probe = await server.exec(
    "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000",
  );
  if (probe.stdout.trim() === "200") {
    ready = true;
    break;
  }
  await new Promise((resolve) => setTimeout(resolve, 1000));
}
if (!ready) throw new Error("Bun server never became ready");

Now map a domain to the VM’s port with freestyle.domains.mappings.create. Pick your own unique subdomain under style.dev; style.dev domains need no verification, so the mapping works immediately.

// Pick a unique subdomain you control.
const domain = `my-app-${crypto.randomUUID().slice(0, 8)}.style.dev`;

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()); // {"message":"Hello from Bun on Freestyle!","path":"/"}

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 bun-server -f\n");

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