Freestyle Docs

Freestyle / Guides

How to Run Python in a Sandbox

Build a reusable VM snapshot with Python, then run many scripts on one long-lived, isolated sandbox VM.

This guide builds a reusable snapshot with Python preinstalled, then defines a small runPython() helper that runs a snippet on a VM you create once and reuse for as many executions as you like.

Install the SDK

pnpm add freestyle@latest
bun add freestyle@latest
npm install freestyle@latest
yarn add freestyle@latest

Set your API key before calling the API:

export FREESTYLE_API_KEY="your-api-key"

Build a Snapshot with Python

Boot a builder VM and install Python with pip and venv. Ubuntu marks the system Python as externally managed, so create a virtual environment under /opt/venv and install dependencies into it. Snapshot the result so the install is baked in.

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: "python-builder" });

const install = await builder.exec(
  "apt-get update -qq && apt-get install -y -qq python3 python3-venv python3-pip",
);
console.log(install.statusCode); // 0

// Create a venv we control, then install dependencies into it.
await builder.exec("python3 -m venv /opt/venv");
await builder.exec("/opt/venv/bin/pip install --quiet requests");

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

apt-installed packages and everything in the venv persist into the snapshot, so every VM you boot from snapshotId has Python ready immediately.

A Reusable runPython() Utility

The VM is the reusable sandbox: create one from the snapshot and run as many snippets on it as you want. runPython() takes that vm as its first argument, writes the code to a file, and runs it with vm.exec — it never creates or deletes a VM, so there’s no per-call boot cost. Each call writes to a unique /tmp/main-<uuid>.py path via crypto.randomUUID(), so runs on the same VM never clobber each other’s file, whether they happen one after another or all at once. vm.exec is synchronous: it runs the script to completion and returns { stdout, stderr, statusCode }. The exec shell is non-login with no HOME and a bare PATH, so call the venv’s Python by its absolute path.

async function runPython(vm, code: string) {
  const file = `/tmp/main-${crypto.randomUUID()}.py`;
  await vm.fs.writeTextFile(file, code);
  return await vm.exec(`/opt/venv/bin/python ${file}`);
}

Create one VM, then keep calling runPython(vm, ...). Here it runs a snippet that uses the requests dependency we baked into the snapshot, then a second snippet on the same VM:

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: "python-sandbox", snapshotId });

const out = await runPython(
  vm,
  [
    "import sys, requests",
    "print('python', sys.version.split()[0])",
    "print('requests', requests.__version__)",
    "print('sum', sum(range(10)))",
  ].join("\n"),
);

console.log(out.statusCode); // 0
console.log(out.stdout);
// python 3.13.5
// requests 2.34.2
// sum 45

// Same VM, another run — no new VM, no clash.
const greeting = await runPython(vm, "print('hello', 2 + 2)");
console.log(greeting.stdout); // hello 4

Do a Bit More

A raw { stdout, stderr, statusCode } is fine, but a useful helper should throw when the code fails and let you pass input. This variant takes the same reusable vm, forwards an args array into Python’s sys.argv, and throws on a non-zero exit, so failures surface as JavaScript exceptions instead of silently returning a non-zero status.

async function runPythonStrict(vm, code: string, { args = [] as unknown[] } = {}) {
  const file = `/tmp/main-${crypto.randomUUID()}.py`;
  await vm.fs.writeTextFile(file, code);
  const quoted = args
    .map((a) => `'${String(a).replace(/'/g, "'\\''")}'`)
    .join(" ");
  const result = await vm.exec(`/opt/venv/bin/python ${file} ${quoted}`);
  if (result.statusCode !== 0) {
    throw new Error(
      `Python exited with ${result.statusCode}:\n${result.stderr ?? result.stdout}`,
    );
  }
  return { stdout: result.stdout, stderr: result.stderr };
}

Run the enhanced variant on the same VM. Pass arguments and read them back from sys.argv:

const sum = await runPythonStrict(
  vm,
  "import sys; print('total:', sum(int(x) for x in sys.argv[1:]))",
  { args: [3, 4, 5] },
);
console.log(sum.stdout); // total: 12

A snippet that raises now rejects instead of returning quietly:

try {
  await runPythonStrict(vm, "raise ValueError('boom')");
} catch (err) {
  console.log(err.message); // Python exited with 1: ... ValueError: boom
}

Because every call writes a uniquely named file, you can even fan out concurrent runs on the one VM without them clashing:

const [a, b] = await Promise.all([
  runPython(vm, "print('A', 111 * 2)"),
  runPython(vm, "print('B', 222 * 2)"),
]);
console.log(a.stdout.trim(), "/", b.stdout.trim()); // A 222 / B 444

Run a Server

The same snapshot that runs one-off scripts can also host a long-running HTTP server. Create a fresh VM from snapshotId (use a distinct variable so it doesn’t collide with the run-code vm), write the server source and a systemd unit, start it, and route a public domain to it.

The server must bind 0.0.0.0 (not 127.0.0.1) so the platform can route external traffic to it. systemd is PID 1 on every VM, so a unit is the right way to keep the process alive. Units do not source a shell profile, so ExecStart uses the absolute venv Python path, and Environment= supplies the HOME the non-login shell omits.

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: "python-server",
  snapshotId,
  idleTimeoutSeconds: null,
});

Write the server. It listens on 0.0.0.0:3000 and answers every request with a plain-text greeting:

const source = `import http.server, socketserver

class Handler(http.server.BaseHTTPRequestHandler):
    def do_GET(self):
        self.send_response(200)
        self.send_header("Content-Type", "text/plain")
        self.end_headers()
        self.wfile.write(b"Hello from a Python server in a sandbox!\\n")

with socketserver.TCPServer(("0.0.0.0", 3000), Handler) as httpd:
    httpd.serve_forever()
`;

await server.fs.writeTextFile("/srv/server.py", source);

Write a systemd unit that runs it. Restart=always keeps it alive, and WantedBy=multi-user.target makes it start on boot:

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

[Service]
ExecStart=/opt/venv/bin/python /srv/server.py
WorkingDirectory=/srv
Environment=HOME=/root
Restart=always

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

await server.fs.writeTextFile("/etc/systemd/system/pyserver.service", unit);

Reload systemd, enable and start the unit, then poll until it serves HTTP 200 on localhost:

const enable = await server.exec(
  "systemctl daemon-reload && systemctl enable --now pyserver",
);
console.log(enable.statusCode); // 0

let ready = false;
for (let i = 0; i < 30 && !ready; i++) {
  await new Promise((r) => setTimeout(r, 1000));
  const probe = await server.exec(
    "curl -s -o /dev/null -w '%{http_code}' http://localhost:3000",
  );
  ready = probe.stdout?.trim() === "200";
}
console.log("ready:", ready); // true

Now route a domain to the VM’s port so the open internet can reach it. 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 },
});

Fetch it from outside the VM:

const res = await fetch(`https://${domain}`);
console.log(res.status); // 200
console.log(await res.text()); // Hello from a Python server in a sandbox!

One snapshot now powers both modes: boot a VM and call runPython for throwaway scripts, or boot another, start a unit, and route a domain to serve traffic from the open internet.

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

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