Freestyle Docs

Freestyle / Guides

How to Run Python with uv in a Sandbox

Bake uv into a VM snapshot, then reuse one sandbox to run many Python scripts with a small helper.

This guide bakes the 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

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

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.

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:

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.

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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

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