---
title: "How to Run Bun in a Sandbox"
description: "Bake the Bun runtime into a VM snapshot, then reuse one sandbox VM to run as much TypeScript and JavaScript as you like."
url: "/docs/guides/run-bun-in-a-sandbox"
---

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

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

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

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

```ts
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 add`s npm packages before the run. Like `runBun`, it never touches the VM lifecycle, so it drops onto the same VM you already booted.

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

```ts
// 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.

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

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

```ts
// 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:

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

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