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@latestbun add freestyle@latestnpm install freestyle@latestyarn add freestyle@latest 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. Ubuntu includes unzip; this recipe checks the installer dependency and uses a fixed 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 freestyle = new Freestyle();
const { vm: builder } = await freestyle.vms.create({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "deno-builder" });
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({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] }, slug: "deno-sandbox", 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 route 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({
// Required: a VM reaches nothing it has not been allowed to.
firewall: { rules: [{ action: "allow", source: {}, destination: { public: true } }] },
slug: "deno-server",
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 routing a domain to it:
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");
Route a domain to the VM’s port. Any unused subdomain of style.dev is yours to take, with no verification and no DNS records; to use a name you own, verify it and point its DNS at Freestyle first:
const domain = "my-app.style.dev"; // any unused style.dev subdomain
await freestyle.tls.rules.create({
action: "allow",
domain,
source: { public: true },
destination: { vmId, port: 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 — route 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.