---
title: "How to Run Python with uv in a Sandbox"
description: "Bake uv into a VM snapshot, then reuse one sandbox to run many Python scripts with a small helper."
url: "/docs/guides/run-python-with-uv-in-a-sandbox"
---

This guide bakes the [uv](https://docs.astral.sh/uv/) Python package manager into a reusable snapshot, then wraps it in a `runUv()` helper that takes a VM as its first argument, writes the code to a uniquely-named file, runs it synchronously, and returns the captured output. You create the sandbox once and run as many scripts on it as you like. The same snapshot also powers a long-lived web 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 uv

Create a builder VM from the minimal base image, install uv, and pin a Python version, then snapshot the machine so the install is baked in. The exec shell is a non-login `sh` with no `HOME` and a bare `PATH`, so set `HOME` and call the uv binary by its absolute path.

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

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

const install = await builder.exec(
  "export HOME=/root && curl -LsSf https://astral.sh/uv/install.sh | sh",
);
console.log(install.statusCode); // 0

const python = await builder.exec(
  "export HOME=/root && /root/.local/bin/uv python install 3.12",
);
console.log(python.statusCode); // 0

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

uv installs to `/root/.local/bin/uv` and the pinned Python lives under `/root/.local/share/uv`, so both persist in the snapshot. Every VM you create from `snapshotId` boots with Python ready, so you only pay the install cost once.


## A reusable runUv() utility

The VM is the reusable sandbox. Create it once and pass it into a helper that writes the code to a uniquely-named file and runs it with `vm.exec`. The helper never creates or deletes the VM, so the same machine serves every call. The `crypto.randomUUID()` filename means sequential or concurrent runs never clash, since no two calls can overwrite each other's script. `vm.exec` runs synchronously and returns `{ stdout, stderr, statusCode }` after the script exits.

```ts
async function runUv(vm, code: string) {
  const file = `/tmp/main-${crypto.randomUUID()}.py`;
  await vm.fs.writeTextFile(file, code);
  return await vm.exec(
    `export HOME=/root && /root/.local/bin/uv run --python 3.12 ${file}`,
  );
}
```

Create one VM from the snapshot, then call `runUv()` as many times as you want on it. The returned `stdout` holds whatever each script printed:

```ts
const { vm } = await freestyle.vms.create({ snapshotId });

const hello = await runUv(
  vm,
  'import platform\nprint("hello from uv", platform.python_version())\n',
);
console.log(hello.stdout); // hello from uv 3.12.13
console.log(hello.statusCode); // 0

const math = await runUv(vm, "print(2 + 2)\n");
console.log(math.stdout); // 4
```


## A stricter runUvStrict() with args, ad-hoc deps, and a typed result

The base `runUv()` hands back a raw `{ stdout, stderr, statusCode }`, which is easy to misuse — it is simple to forget to check `statusCode`. Add a second, stricter helper, `runUvStrict()`, that lives alongside `runUv()` and makes the safe behavior the default: it accepts command-line arguments, installs ad-hoc dependencies for a single run, throws when the script exits non-zero, and returns just the captured streams. Like `runUv()`, it still takes the same `vm` so you keep reusing one sandbox. uv's `--with` flag installs packages into an ephemeral environment just for that run, and `sys.argv` receives anything you append after the script path.

```ts
async function runUvStrict(
  vm,
  code: string,
  { args = [], dependencies = [] }: { args?: string[]; dependencies?: string[] } = {},
): Promise<{ stdout: string; stderr: string }> {
  const file = `/tmp/main-${crypto.randomUUID()}.py`;
  await vm.fs.writeTextFile(file, code);

  const withFlags = dependencies.flatMap((d) => ["--with", d]);
  const command = [
    "export HOME=/root &&",
    "/root/.local/bin/uv run --python 3.12",
    ...withFlags,
    file,
    ...args,
  ].join(" ");

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

Run it against the same `vm`. Pass arguments through `sys.argv`, or name a dependency to install it on the fly:

```ts
const argv = await runUvStrict(vm, 'import sys\nprint("args:", " ".join(sys.argv[1:]))\n', {
  args: ["alpha", "beta"],
});
console.log(argv.stdout); // args: alpha beta

const cow = await runUvStrict(vm, 'import cowsay\ncowsay.cow("uv installs deps on demand")\n', {
  dependencies: ["cowsay"],
});
console.log(cow.stdout.includes("uv installs deps on demand")); // true
```

Because each call writes its own uniquely-named file, you can even fan out runs concurrently on the one VM without them clashing:

```ts
const [one, two] = await Promise.all([
  runUvStrict(vm, "import sys\nprint(sys.argv[1])\n", { args: ["one"] }),
  runUvStrict(vm, "import sys\nprint(sys.argv[1])\n", { args: ["two"] }),
]);
console.log(one.stdout, two.stdout); // one  two
```

A script that exits non-zero rejects the promise:

```ts
await runUvStrict(vm, 'raise SystemExit("boom")\n'); // throws: Python exited with status 1: boom
```


## Run a Server

The same uv snapshot is all you need to host a long-lived web server. Create a fresh VM from it — give the binding a distinct name like `server` so it stays separate from the run-code `vm` above — and pass `idleTimeoutSeconds: null` so the machine stays up while it serves traffic:

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

Write the server source to `/srv`. The app must bind `0.0.0.0` (not `127.0.0.1`) so traffic routed to the VM can reach it:

```ts
const source = `from flask import Flask

app = Flask(__name__)


@app.get("/")
def index():
    return "hello from a uv-managed python server\\n"


if __name__ == "__main__":
    app.run(host="0.0.0.0", port=3000)
`;

await server.exec("mkdir -p /srv");
await server.fs.writeTextFile("/srv/server.py", source);
```

Initialize a uv project in `/srv` and add Flask. uv writes a `pyproject.toml` and a locked virtual environment under `/srv`:

```ts
const deps = await server.exec(
  "export HOME=/root && cd /srv && " +
    "/root/.local/bin/uv init --python 3.12 --no-readme --bare && " +
    "/root/.local/bin/uv add flask",
);
console.log(deps.statusCode); // 0
```

Register the server as a systemd service. systemd is PID 1 on the VM, so a unit with `Restart=always` keeps the server alive and brings it back on boot. Units do not source a shell profile, so use the absolute `uv` path and declare `Environment=HOME=/root` and `WorkingDirectory=/srv` explicitly:

```ts
const unit = `[Unit]
Description=uv python server
After=network.target

[Service]
Environment=HOME=/root
WorkingDirectory=/srv
ExecStart=/root/.local/bin/uv run --python 3.12 server.py
Restart=always

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

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

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

Wait until the server actually answers on localhost before routing traffic to it. Poll until you see HTTP `200`:

```ts
let ready = false;
for (let attempt = 0; attempt < 40; attempt++) {
  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("server never became ready");
```

Map a public domain to the VM's port. `style.dev` subdomains need no verification, and each run gets a unique one:

```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 uv-managed python server
```

You now have one uv snapshot that does double duty: pass a VM into `runUv()` to run scripts on demand, or create a VM from the same image, register a systemd service, and route a public domain straight to it.


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

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