Run untrusted code in Firecracker microVMs

A real VM per sandbox, on your own hardware. One Go daemon, a CLI, and SDKs for TypeScript, Python and Go generated from a single OpenAPI spec.

$ npm install @pablofdezr/microvm

TypeScript today · Python & Go SDKs live in the repo

Quick start

Boot a VM, run a command, tear it down

The same shape in every language. Create a sandbox, run untrusted code inside it, read the output, delete it.

import { Client } from "@pablofdezr/microvm";

const client = new Client("http://127.0.0.1:8080", { token });

const sb = await client.sandboxes.create({ image: "python" });
try {
  const exe = await client.run(sb.id, "python3", ["-c", "print('hi')"]);
  console.log(exe.stdout);
} finally {
  await client.sandboxes.delete(sb.id);
}
from microvm import Client

client = Client("http://127.0.0.1:8080", token)

sb = client.sandboxes.create("python")
try:
    exe = client.run(sb["id"], "python3", "-c", "print('hi')")
    print(exe["stdout"], end="")
finally:
    client.sandboxes.delete(sb["id"])
client := microvm.New("http://127.0.0.1:8080", microvm.WithToken(token))
ctx := context.Background()

sb, err := client.Sandboxes.Create(ctx, microvm.SandboxCreateParams{Image: "python"})
if err != nil {
    log.Fatal(err)
}
defer client.Sandboxes.Delete(ctx, sb.Id)

exe, _ := client.Run(ctx, sb.Id, "python3", "-c", "print('hi')")
fmt.Print(exe.Stdout)

Examples

Files, streams, queues and full nodes

Everything below is the same client. Pick a language once — it applies to every snippet on the page.

Write a file in, read the result out
const source = `
import json
json.dump({"ok": True}, open("out.json", "w"))
`;

const sb = await client.sandboxes.create({ image: "python" });
await client.files.write(sb.id, "main.py", source);
await client.run(sb.id, "python3", ["main.py"]);

const out = await client.files.readText(sb.id, "out.json");
console.log(JSON.parse(out).ok);
source = """
import json
json.dump({"ok": True}, open("out.json", "w"))
"""

sb = client.sandboxes.create("python")
client.files.write(sb["id"], "main.py", source)
client.run(sb["id"], "python3", "main.py")

out = client.files.read(sb["id"], "out.json")
print(json.loads(out)["ok"])
const source = `
import json
json.dump({"ok": True}, open("out.json", "w"))
`

sb, _ := client.Sandboxes.Create(ctx, microvm.SandboxCreateParams{Image: "python"})
defer client.Sandboxes.Delete(ctx, sb.Id)

client.Files.Write(ctx, sb.Id, "main.py", []byte(source))
client.Run(ctx, sb.Id, "python3", "main.py")

out, _ := client.Files.Retrieve(ctx, sb.Id, "out.json")
fmt.Println(string(out))
Follow the output as it is produced
import { frameText } from "@pablofdezr/microvm";

// Starting a command and watching it are two calls: a dropped
// connection no longer kills the job.
const exe = await client.executions.create(sb.id, {
  cmd: "python3",
  args: ["train.py"],
  timeout_seconds: 600,
});

// The stream replays from the start, then follows — reconnecting loses nothing.
for await (const frame of client.executions.stream(sb.id, exe.id)) {
  if (frame.type === "stdout") process.stdout.write(frameText(frame));
}
# Starting a command and watching it are two calls: a dropped
# connection no longer kills the job.
exe = client.executions.create(
    sb["id"], "python3", ["train.py"], timeout_seconds=600
)

# The stream replays from the start, then follows — reconnecting loses nothing.
for frame in client.executions.stream(sb["id"], exe["id"]):
    if frame["type"] == "stdout":
        sys.stdout.buffer.write(frame["data"])
// Starting a command and watching it are two calls: a dropped
// connection no longer kills the job.
exe, _ := client.Executions.Create(ctx, sb.Id, microvm.ExecutionCreateParams{
    Cmd:            "python3",
    Args:           &[]string{"train.py"},
    TimeoutSeconds: microvm.Ptr(600),
})

// The stream replays from the start, then follows — reconnecting loses nothing.
for frame, err := range client.Executions.Stream(ctx, sb.Id, exe.Id) {
    if err != nil {
        log.Fatal(err)
    }
    os.Stdout.Write(frame.Bytes())
}
Queue a task and wait for the result
// No sandbox to hold: the task waits for a slot anywhere in the fleet.
const task = await client.tasks.create({
  image: "node",
  cmd: "node",
  args: ["job.js"],
  files: { "job.js": source },
  vcpus: 2,
  mem_mib: 1024,
  priority: 7, // 0–10, higher runs first; equal is FIFO
});

const done = await client.tasks.wait(task.id);
console.log(done.status, done.exit_code, done.stdout);
# No sandbox to hold: the task waits for a slot anywhere in the fleet.
task = client.tasks.create(
    "node", "node",
    args=["job.js"],
    files={"job.js": source},
    vcpus=2,
    mem_mib=1024,
    priority=7,  # 0-10, higher runs first; equal is FIFO
)

done = client.tasks.wait(task["id"])
print(done["status"], done["exit_code"], done["stdout"])
// No sandbox to hold: the task waits for a slot anywhere in the fleet.
// Optional fields are pointers, so microvm.Ptr sets them inline.
task, _ := client.Tasks.Create(ctx, microvm.TaskCreateParams{
    Image:    "node",
    Cmd:      "node",
    Args:     &[]string{"job.js"},
    Vcpus:    microvm.Ptr(2),
    MemMib:   microvm.Ptr(1024),
    Priority: microvm.Ptr(7), // 0-10, higher runs first; equal is FIFO
})

done, _ := client.Tasks.Wait(ctx, task.Id)
fmt.Print(done.Stdout)
Cap the sandbox, and handle a full node
import { APIError } from "@pablofdezr/microvm";

try {
  const sb = await client.sandboxes.create({
    image: "go",
    vcpus: 2,
    mem_mib: 1024,
    cpu_cores: 0.5,   // a hard ceiling, enforced
    network: true,    // filtered egress — no RFC1918, no metadata
    ttl_seconds: 300, // killed when it elapses
  });
} catch (e) {
  // The node is full. Hand the work to the queue instead of failing.
  if (e instanceof APIError && e.isCapacity) {
    await client.tasks.create({ image: "go", cmd: "go", args: ["run", "main.go"] });
  } else throw e;
}
from microvm import APIError

try:
    sb = client.sandboxes.create(
        "go",
        vcpus=2,
        mem_mib=1024,
        cpu_cores=0.5,    # a hard ceiling, enforced
        network=True,     # filtered egress - no RFC1918, no metadata
        ttl_seconds=300,  # killed when it elapses
    )
except APIError as e:
    if not e.is_capacity:
        raise
    # The node is full. Hand the work to the queue instead of failing.
    client.tasks.create("go", "go", args=["run", "main.go"])
sb, err := client.Sandboxes.Create(ctx, microvm.SandboxCreateParams{
    Image:      "go",
    Vcpus:      microvm.Ptr(2),
    MemMib:     microvm.Ptr(1024),
    CpuCores:   microvm.Ptr(0.5),  // a hard ceiling, enforced
    Network:    microvm.Ptr(true), // filtered egress — no RFC1918, no metadata
    TtlSeconds: microvm.Ptr(300),  // killed when it elapses
})

// The node is full. Hand the work to the queue instead of failing.
if microvm.IsCapacity(err) {
    _, err = client.Tasks.Create(ctx, microvm.TaskCreateParams{
        Image: "go", Cmd: "go", Args: &[]string{"run", "main.go"},
    })
}
Or skip the SDK entirely
microvm run python main.py              # upload, run, print the output
microvm run node app.ts -network        # with filtered internet
microvm run python job.py -env KEY=v -timeout 30s

microvm submit python job.py            # queue it instead; prints a task ID
microvm result tsk_01JZ8...             # wait for it and print the output

microvm ps                              # sandboxes on this node
microvm queue                           # depth and this node's slots
microvm bench python main.py -n 10      # time each leg separately
microvm logs sb_01JZ8... exe_01JZ8...   # an execution's recorded output

The exit code is the program's own, so microvm run python test.py && deploy composes. Ctrl-C aborts the process inside the guest, not just the CLI.

Why microvm

Built for code you don't trust

The code inside a sandbox is assumed hostile. Every design decision falls out of that.

Real VM isolation

A Firecracker microVM per sandbox — a separate kernel behind a hardware boundary, not a shared runtime.

Sandboxes & tasks

Hold a VM and drive it, or hand work to the fleet and let a resource-aware queue place it.

One VPS to a fleet

Single box in-process, or hundreds of nodes behind a shared Redis queue. Same binary, shaped by flags.

Verified boot NEW

dm-verity checks every block of the read-only rootfs at boot — the kernel panics before init if the image was tampered with.

Fast cold starts NEW

Sandboxes boot quiet, because the kernel narrating a successful boot to an emulated UART was half the boot — that alone took a create from 288 ms to 170 ms. On top of it: warm build caches baked into the images, plus a pool of pre-booted VMs that skips the boot entirely, optionally filled by restoring a Firecracker snapshot, which a guest answers in tens of milliseconds. Restores are a pool optimisation: each one is a fresh VM with its own rotated CSPRNG, not a saved sandbox you can come back to.

Auth & quotas

Per-tenant bearer tokens, storage quotas and metering. A firewall blocks RFC1918, link-local and cloud metadata.

Metering & logs

CPU, memory and storage accounted per tenant. Exec output outlives the VM, so you can collect it later.

SDKs from one spec

TypeScript, Python and Go clients generated from a single OpenAPI spec — server and SDKs agree by construction.

Your hardware

Runs anywhere with KVM — bare metal or a Pi 5. No vendor, no per-second bill, no code leaving your box.

Two ways to run

Backpressure is yours, or the fleet's

Pick the model per workload. A sandbox gives you a VM to drive; a task waits for a slot anywhere in the fleet.

Sandbox you hold the VM

create throws a capacity error when the node is full, so you decide how to shed load. Upload files, run many commands, stream output.

Task the fleet places it

Never fails for capacity — it waits for a slot on any node, sized to the vCPUs and memory you request, with a priority you set.

Benchmarks

Measured on a Raspberry Pi 5

Real code, timed one leg at a time with microvm bench — 10 runs after a discarded warm-up, image hot in the page cache. Medians.

ImageSizeBootRun the codeTeardownTotal
python154 MB167 ms78 ms24 ms281 ms
node · tsx303 MB229 ms943 ms46 ms1.20 s
go · Alpine620 MB255 ms841 ms53 ms1.17 s
rust859 MB304 ms846 ms62 ms1.15 s

The split is the point. Booting the sandbox costs 170–300 ms and barely tracks image size — rootfs images are hardlinked into each jail, not copied. What varies is Run the code: a compiler for Go and Rust, the tsx transform for node, 71–79% of those three totals and no part of it a cost of starting a microVM.

Inside one bootMedian
Stage the jail — hardlink kernel and rootfs, render vm.json, chown0.5 ms
Exec the jailer, which execs Firecracker0.7 ms
Guest kernel, up to the point it execs our init82 ms
Overlay root, mounts, network, env, storage5 ms
VMM start-up, supervisor re-exec, agent binding vsock, health-poll granularity119 ms
One create, end to end214 ms

42 cold boots across the four images. Host work is ~1 ms of it. The guest kernel is the largest named phase, which is why sandboxes boot quiet: every kernel printk is a synchronous write to an emulated UART the guest blocks on, so letting it narrate a successful boot cost 87 ms — quieting it took the guest kernel from 169 ms to 82 ms and a create from 288 ms to 170 ms. The console stays attached at KERN_ERR, so a panic still prints with its call trace.

That before-and-after is one image measured twice, so it was checked against a second: a separately built python rootfs, served by a different daemon on the same host, creates in 152 ms quiet — consistent with the 170 ms above rather than particular to the image the table was built from. The 119 ms remainder is the next thing worth attacking.

Open source · Apache-2.0

Run it on your own box

Written entirely in Go. Clone the repo, build the images, and put a TLS terminator in front — the operator's guide walks you through it.