Freestyle Docs

Freestyle / Guides

How to Run Deno in a Sandbox

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.

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

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

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.

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.

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.

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:

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:

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:

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:

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:

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

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