---
title: "How to Run Deno in a Sandbox"
description: "Bake the Deno runtime into a VM snapshot, then reuse one isolated sandbox to run as many TypeScript snippets as you like — or boot the same snapshot as a public HTTP server."
url: "/docs/guides/run-deno-in-a-sandbox"
---

This guide builds a reusable snapshot with the Deno runtime preinstalled, then wraps it in a `runDeno()` helper that takes a VM and runs one TypeScript snippet on it synchronously. Boot a single VM from the snapshot and reuse it across many runs — sequentially or concurrently. The same snapshot also boots a long-lived HTTP server you can route a public domain to.


## 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 Deno Runtime

Create a builder VM, install Deno explicitly, and snapshot it so the runtime is baked in. The base image is minimal, so install `unzip` (the Deno installer needs it) and pin an install directory. The exec shell is non-login with no `HOME`, so set one for the installer. Delete the builder VM once the snapshot is captured.

```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 curl");
await builder.exec(
  "export HOME=/root && export DENO_INSTALL=/opt/deno && curl -fsSL https://deno.land/install.sh | sh",
);

// Verify the binary landed at /opt/deno/bin/deno
const version = await builder.exec("/opt/deno/bin/deno --version");
console.log(version.stdout); // deno 2.8.2 (stable, release, x86_64-unknown-linux-gnu)

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

console.log("snapshot ready:", snapshotId);
```


## A Reusable runDeno() Utility

With the snapshot in hand, the run helper is small. It takes a `vm` as its first argument, writes the code to a file, and runs it once with `vm.exec` — no VM lifecycle inside the helper. `vm.exec` is synchronous and returns `{ stdout, stderr, statusCode }`. Use the absolute binary path, since the exec shell has a minimal `PATH`.

```ts
async function runDeno(vm, code: string) {
  const file = `/tmp/main-${crypto.randomUUID()}.ts`;
  await vm.fs.writeTextFile(file, code);
  return await vm.exec(`/opt/deno/bin/deno run ${file}`);
}

// One VM is a reusable sandbox: boot it once, run as many snippets as you like.
const { vm } = await freestyle.vms.create({ snapshotId });

const result = await runDeno(
  vm,
  `const greet = (name: string): string => \`Hello, \${name}, from Deno \${Deno.version.deno}!\`;
console.log(greet("there"));`,
);
console.log(result.stdout); // Hello, there, from Deno 2.8.2!

// Reuse the same VM for another run — no new VM needed.
const sum = await runDeno(vm, `console.log(2 + 40);`);
console.log(sum.stdout); // 42
```

Each call writes to a unique `/tmp/main-<uuid>.ts` path via `crypto.randomUUID()`, so reusing one VM is safe even when runs happen concurrently — no two calls can clobber each other's file. The VM itself is the reusable sandbox: create it once and run any number of snippets. Deno is secure by default, so a program that needs file, network, or env access has to opt in with `--allow-*` flags.


## Do a Bit More: Typed Results, Permissions, and Args

`runDeno()` is fine for quick runs, but a real helper should fail loudly and let the caller grant capabilities. Add a second, stricter helper alongside it — `runDenoStrict()` — that still takes the `vm` first, throws on a non-zero exit, accepts a `permissions` list that maps to Deno's `--allow-*` flags, and forwards `args` to `Deno.args`. It is a distinct function from `runDeno`, so both can live in the same script, and it runs on the same reusable VM you created above.

```ts
interface RunOptions {
  permissions?: string[]; // e.g. ["net", "read"] -> --allow-net --allow-read
  args?: string[]; // forwarded to Deno.args
}

async function runDenoStrict(vm, code: string, { permissions = [], args = [] }: RunOptions = {}) {
  const file = `/tmp/main-${crypto.randomUUID()}.ts`;
  await vm.fs.writeTextFile(file, code);
  const flags = permissions.map((p) => `--allow-${p}`).join(" ");
  const argv = args.map((a) => `'${a}'`).join(" ");
  const cmd = `/opt/deno/bin/deno run ${flags} ${file} ${argv}`.trim();

  const r = await vm.exec(cmd);
  if (r.statusCode !== 0) {
    throw new Error(`Deno exited ${r.statusCode}:\n${r.stderr ?? ""}`);
  }
  return { stdout: r.stdout ?? "", stderr: r.stderr ?? "", statusCode: r.statusCode };
}

// Same `vm` from above — pass args through to Deno.args.
const echo = await runDenoStrict(vm, `console.log(\`args: \${Deno.args.join(", ")}\`);`, {
  args: ["alpha", "beta"],
});
console.log(echo.stdout); // args: alpha, beta

// Grant network access to fetch a real endpoint.
const fetched = await runDenoStrict(
  vm,
  `const r = await fetch("https://example.com");\nconsole.log(r.status);`,
  { permissions: ["net"] },
);
console.log(fetched.stdout); // 200

// Unique filenames keep concurrent runs on the one VM from clashing.
const [a, b] = await Promise.all([
  runDenoStrict(vm, `console.log("A");`),
  runDenoStrict(vm, `console.log("B");`),
]);
console.log(a.stdout, b.stdout); // A  B
```

A snippet that throws now surfaces as a rejected promise instead of a silent non-zero exit, so failures are easy to catch in calling code. Use `runDeno` for quick, fire-and-forget runs and `runDenoStrict` when you want failures to throw and need permissions or args.


## Run a Server

The same runtime snapshot runs a long-lived HTTP server. Instead of executing one-shot snippets, boot a fresh VM from `snapshotId`, write the server source plus a systemd unit that keeps it running, then map a public domain to it. Use a distinct `server` binding so it lives alongside the run-code VM above — `idleTimeoutSeconds: null` keeps the server from being paused while idle.

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

Write the server to `/srv`. `Deno.serve` binds `0.0.0.0` so traffic from outside the VM reaches it:

```ts
await server.exec("mkdir -p /srv");
await server.fs.writeTextFile(
  "/srv/server.ts",
  `Deno.serve({ port: 3000, hostname: "0.0.0.0" }, (req) => {
  const url = new URL(req.url);
  return new Response(
    JSON.stringify({ message: "Hello from Deno!", path: url.pathname }),
    { headers: { "content-type": "application/json" } },
  );
});`,
);
```

systemd is PID 1 in the VM. A unit file keeps the server running and restarts it if it crashes. Units do not source a shell profile, so use the absolute path to the `deno` binary in `ExecStart` and set `HOME` explicitly:

```ts
await server.fs.writeTextFile(
  "/etc/systemd/system/deno-server.service",
  `[Unit]
Description=Deno HTTP server
After=network.target

[Service]
Environment=HOME=/root
ExecStart=/opt/deno/bin/deno run --allow-net /srv/server.ts
WorkingDirectory=/srv
Restart=always

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

Enable and start the service, then wait until it actually serves HTTP `200` on `localhost` before mapping a domain:

```ts
await server.exec("systemctl daemon-reload && systemctl enable --now deno-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, 2000));
}
if (!ready) throw new Error("Deno server never became ready");
```

Map a domain to the VM's port. `style.dev` subdomains need no verification, so the route is live immediately, and each run gets a unique subdomain:

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

Now the server is reachable from anywhere. 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 Deno!","path":"/"}
```

The snapshot is your reusable image: it boots either as a sandbox for one-shot snippets or as a server you route a domain to. You pay the install cost once, then launch as many VMs from it as you need — map a fresh domain to each server VM to run multiple copies side by side.


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

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