---
title: "How to Run Python in a Sandbox"
description: "Build a reusable VM snapshot with Python, then run many scripts on one long-lived, isolated sandbox VM."
url: "/docs/guides/run-python-in-a-sandbox"
---

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

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

Boot a builder VM and install Python with `pip` and `venv`. Debian 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.

```ts
import { freestyle } from "freestyle";

const { vm: builder } = await freestyle.vms.create();

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.

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

```ts
const { vm } = await freestyle.vms.create({ 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.

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

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

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

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

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

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

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

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

```ts
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 map a domain to the VM's port so the open internet can reach it. `style.dev` needs no verification, so the mapping is live as soon as you create it, 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 });
```

Fetch it from outside the VM:

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

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