Back to Microsandbox

Overview

docs/sandboxes/overview.mdx

0.4.610.9 KB
Original Source

A sandbox is a microVM: a real virtual machine with its own Linux kernel, filesystem, and network stack, running as a child process of whatever application creates it.

The security boundary here is hardware virtualization, not Linux namespaces. Container escapes are a well-documented class of vulnerability; breaking out of a microVM requires exploiting the hypervisor itself, which is a fundamentally harder problem.

Creating a sandbox

At minimum, a sandbox needs a name and an image. Everything else has sensible defaults: 1 vCPU, 512 MiB memory, public-only networking, /bin/sh as the shell.

<CodeGroup> ```rust Rust let sb = Sandbox::builder("worker") .image("python") .create() .await?; ```
typescript
await using sb = await Sandbox.builder("worker")
    .image("python")
    .create();
python
sb = await Sandbox.create("worker", image="python")
bash
msb create python --name worker
</CodeGroup>

Configuration options

<CodeGroup> ```rust Rust let sb = Sandbox::builder("worker") .image("python") .memory(1024) .cpus(2) .env("DEBUG", "true") .env("API_PORT", "8000") .volume("/app/src", |v| v.bind("./src").readonly()) .volume("/data", |v| v.named("my-data")) .volume("/tmp/scratch", |v| v.tmpfs().size(100)) .create() .await?; ```
typescript
import { Sandbox } from "microsandbox";

await using sb = await Sandbox.builder("worker")
    .image("python")
    .memory(1024)
    .cpus(2)
    .env("DEBUG", "true")
    .env("API_PORT", "8000")
    .volume("/app/src",     (m) => m.bind("./src").readonly())
    .volume("/data",        (m) => m.named("my-data"))
    .volume("/tmp/scratch", (m) => m.tmpfs().size(100))
    .create();
python
from microsandbox import Sandbox, Volume

sb = await Sandbox.create(
    "worker",
    image="python",
    memory=1024,
    cpus=2,

    env={"DEBUG": "true", "API_PORT": "8000"},
    volumes={
        "/app/src": Volume.bind("./src", readonly=True),
        "/data": Volume.named("my-data"),
        "/tmp/scratch": Volume.tmpfs(size_mib=100),
    },
)
bash
msb create python --name worker \
  -c 2 \
  -m 1G \
  -e DEBUG=true \
  -e API_PORT=8000 \
  -v ./src:/app/src:ro \
  -v my-data:/data \
  --tmpfs /tmp/scratch:100
</CodeGroup>
OptionDefaultDescription
imageOCI image, local path, or disk image
cpus1Number of virtual CPUs
memory512Guest memory in MiB
workdirDefault working directory for commands
shell/bin/shShell used by shell() calls
env{}Environment variables
volumes[]Volume mounts (bind, named, or tmpfs)
networkpublicNetwork policy and port mappings
scripts{}Named scripts stored at /.msb/scripts/
<Tip> `cpus` and `memory` are **limits, not reservations**. Setting `memory: 512` doesn't allocate 512 MiB upfront. Physical pages are only allocated as the guest actually touches them, so you can comfortably run many sandboxes on a single host without worrying about overcommitting. </Tip>

Rootfs sources

microsandbox supports three ways to provide a root filesystem. The choice affects how the filesystem is assembled and what features are available.

OCI images

The most common option. microsandbox pulls the image and stacks its layers as a copy-on-write filesystem. Changes inside the sandbox don't modify the base image. If two sandboxes use the same image, they share the same cached layers on disk.

<CodeGroup> ```rust Rust Sandbox::builder("worker").image("python").create().await?; ```
typescript
await Sandbox.builder("worker").image("python").create();
python
await Sandbox.create("worker", image="python")
bash
msb create python --name worker
</CodeGroup>

Bind mounts

Use a local directory on the host as the root filesystem directly. The guest sees the directory contents as its /. This is useful for development when you have a pre-built rootfs, or for minimal environments where you've assembled the filesystem yourself.

<CodeGroup> ```rust Rust Sandbox::builder("worker").image("./my-rootfs").create().await?; ```
typescript
await Sandbox.builder("worker").image("./my-rootfs").create();
python
await Sandbox.create("worker", image="./my-rootfs")
bash
msb create ./my-rootfs --name worker
</CodeGroup> <Note> The guest agent is automatically included in the rootfs during sandbox creation, regardless of rootfs source. You don't need to add anything to your image or directory. </Note>

Disk images

Boot from a QCOW2, Raw, or VMDK disk image. Unlike OCI images (which use a copy-on-write overlay), disk images give the guest raw block device access. See Disk Images for details.

<CodeGroup> ```rust Rust // Auto-detect filesystem Sandbox::builder("worker") .image("./ubuntu-22.04.qcow2") .create() .await?;

// Explicit filesystem type Sandbox::builder("worker") .image_with(|i| i.disk("./alpine.raw").fstype("ext4")) .create() .await?;


```typescript TypeScript
// Auto-detect the disk image format from the file extension.
await Sandbox.builder("worker")
    .image("./alpine.qcow2")
    .create();
python
from microsandbox import Image, Sandbox

# Auto-detect filesystem
await Sandbox.create("worker", image="./ubuntu-22.04.qcow2")

# Explicit filesystem type
await Sandbox.create("worker", image=Image.disk("./alpine.raw", fstype="ext4"))
bash
msb create ./alpine.qcow2 --name worker
</CodeGroup> <Note> With OCI images, microsandbox stacks layers and adds a copy-on-write overlay so sandboxes share the base. With disk images, the guest gets the block device directly, so there's no copy-on-write isolation between sandboxes using the same disk image. Each sandbox needs its own copy (or use QCOW2's built-in snapshot/backing file support). </Note>

Replace existing

Replace a stopped or running sandbox with the same name instead of failing on conflict. This stops the old sandbox (if still running), removes it, and creates a fresh one.

<CodeGroup> ```rust Rust let sb = Sandbox::builder("worker") .image("python") .replace() .create() .await?; ```
typescript
await using sb = await Sandbox.builder("worker")
    .image("python")
    .replace()
    .create();
python
sb = await Sandbox.create("worker", image="python", replace=True)
bash
msb create --replace python --name worker
</CodeGroup>

Grace period

If the existing sandbox is still running, microsandbox sends it SIGTERM and waits for it to exit before falling back to SIGKILL. The default wait is 10 seconds. Pass a longer grace if the workload needs more time to shut down cleanly, or 0 to skip SIGTERM and kill immediately.

<CodeGroup> ```rust Rust use std::time::Duration;

let sb = Sandbox::builder("worker") .image("python") .replace_with_grace(Duration::from_secs(30)) .create() .await?;


```typescript TypeScript
await using sb = await Sandbox.builder("worker")
    .image("python")
    .replaceWithGrace(30_000)  // milliseconds
    .create();
python
sb = await Sandbox.create("worker", image="python", replace_with_grace=30)  # seconds
bash
msb create --replace-with-grace 30s python --name worker
msb create --replace-with-grace 0 python --name worker  # SIGKILL immediately
</CodeGroup>

replace_with_grace / --replace-with-grace implies replace / --replace, so you don't need to pass both.

Private registry

Authenticate to private container registries and control when images are pulled.

<CodeGroup> ```rust Rust use microsandbox::{Sandbox, RegistryAuth, PullPolicy};

let sb = Sandbox::builder("worker") .image("registry.corp.io/team/app:latest") .registry_auth(RegistryAuth::Basic { username: "deploy".into(), password: std::env::var("REGISTRY_PASSWORD")?, }) .pull_policy(PullPolicy::Always) .create() .await?;


```typescript TypeScript
import { Sandbox } from "microsandbox";

await using sb = await Sandbox.builder("worker")
    .image("private.registry.io/org/app:v1")
    .registry((r) => r.auth({
        kind: "basic",
        username: "deploy",
        password: process.env.REGISTRY_TOKEN!,
    }))
    .pullPolicy("always")
    .create();
python
import os
from microsandbox import PullPolicy, RegistryAuth, Sandbox

sb = await Sandbox.create(
    "worker",
    image="private.registry.io/org/app:v1",
    registry_auth=RegistryAuth.basic("deploy", os.environ["REGISTRY_PASSWORD"]),
    pull_policy=PullPolicy.ALWAYS,
)
bash
msb registry login private.registry.io -u deploy
msb create private.registry.io/org/app:v1 \
  --name worker --pull always
</CodeGroup>
PolicyBehavior
"always"Pull the image every time, even if cached locally
"if-missing"Pull only if the image is not already cached (default)
"never"Never pull; fail if the image is not cached
<Tip> Once an OCI image is resolved, microsandbox pins the exact layers. A `python` reference is resolved to an immutable set of layers at first pull. Subsequent `start()` calls use the pinned layers without re-resolving the mutable tag, so your sandbox is reproducible even if the upstream tag is updated. </Tip>

Config inspection <sup><sup>coming soon</sup></sup>

Build a sandbox configuration without booting the VM. Useful for serializing, storing, validating, or modifying configs before creation.

<CodeGroup> ```rust Rust let config = Sandbox::builder("preview") .image("python") .memory(1024) .build()?;

println!("{}", serde_json::to_string_pretty(&config)?); let sb = Sandbox::create(config).await?;


```typescript TypeScript
const builder = Sandbox.builder("preview").image("python").memory(1024);
const config = builder.build();

console.log(JSON.stringify(config, null, 2));
const sb = await builder.create();
python
import json
from microsandbox import Sandbox

config = Sandbox.build_config(
    "preview",
    image="python",
    memory=1024,
)

print(json.dumps(config, indent=2))
sb = await Sandbox.create(config)
</CodeGroup>

Customizing the guest environment

If you need to run setup logic, install packages, or inject files before a sandbox starts doing real work, there are two ways to do it without building a custom image:

  • Scripts are files mounted into the sandbox at /.msb/scripts/ and added to PATH. Define them at creation time and call them by name with exec() or shell(). Good for multi-step setup procedures or named entry points.
  • Patches modify the rootfs before the VM boots: write config files, copy directories from the host, create symlinks, append to existing files, etc. The base image stays untouched since patches go into the writable layer.