docs/sandboxes/customize.mdx
Before a sandbox starts doing real work, you can prepare it three ways. Scripts bundle reusable commands. Patches modify the rootfs before the VM boots. A custom init system (systemd, OpenRC, s6) can run as PID 1 instead of microsandbox's minimal agent. All three are defined at creation time and keep the base image untouched.
Scripts are files mounted at /.msb/scripts/ inside the sandbox. The directory is on PATH, so each script is callable by name through exec() or shell().
It provides a clean way to bundle setup procedures or entry points with a sandbox without baking them into the image.
<CodeGroup> ```rust Rust use indoc::indoc; use microsandbox::Sandbox;let sb = Sandbox::builder("worker") .image("ubuntu") .script("setup", indoc! {" #!/bin/bash apt-get update && apt-get install -y python3 curl "}) .script("start", indoc! {" #!/bin/bash exec python3 /app/main.py "}) .create() .await?;
sb.shell("setup").await?; let output = sb.shell("start").await?;
```typescript TypeScript
import { Sandbox } from "microsandbox";
await using sb = await Sandbox.builder("worker")
.image("ubuntu")
.script("setup", "#!/bin/bash\napt-get update && apt-get install -y python3 curl")
.script("run", "#!/bin/bash\nexec python3 /app/main.py")
.create();
await sb.shell("setup");
const output = await sb.shell("run");
from microsandbox import Sandbox
sb = await Sandbox.create(
"worker",
image="ubuntu",
scripts={
"setup": "#!/bin/bash\napt-get update && apt-get install -y python3 curl",
"start": "#!/bin/bash\nexec python3 /app/main.py",
},
)
await sb.shell("setup")
output = await sb.shell("start")
sb, err := m.CreateSandbox(ctx, "worker",
m.WithImage("ubuntu"),
m.WithScripts(map[string]string{
"setup": "#!/bin/bash\napt-get update && apt-get install -y python3 curl",
"start": "#!/bin/bash\nexec python3 /app/main.py",
}),
)
_, err = sb.Shell(ctx, "setup")
output, err := sb.Shell(ctx, "start")
Patches modify the rootfs before the VM boots. Write config files, copy directories from the host, create symlinks, append to existing files, remove things you don't need. The base image stays untouched since patches are written to the writable layer on top.
Patches are applied in order and work with OCI images and bind-mounted rootfs. They're not supported with disk image roots (QCOW2, Raw).
<Tip> By default, patching a path that already exists in the image will error. Pass `replace: true` on the operation to allow it. `Mkdir` and `Remove` are idempotent and won't error either way. </Tip> <CodeGroup> ```rust Rust use microsandbox::Sandbox;let sb = Sandbox::builder("worker") .image("alpine") .patch(|p| p .text("/etc/greeting.txt", "Hello from a patched rootfs!\n", None, false) .text("/etc/motd", "Custom message of the day.\n", None, true) // replace existing .mkdir("/app", Some(0o755)) .text("/app/config.json", r#"{"debug": true}"#, Some(0o644), false) .copy_file("./cert.pem", "/etc/ssl/cert.pem", None, false) .append("/etc/hosts", "127.0.0.1 myapp.local\n") ) .create() .await?;
```typescript TypeScript
import { Sandbox } from "microsandbox";
await using sb = await Sandbox.builder("worker")
.image("alpine")
.patch((p) => p
.text("/etc/greeting.txt", "Hello from a patched rootfs!\n")
.text("/etc/motd", "Custom message of the day.\n", { replace: true })
.mkdir("/app", { mode: 0o755 })
.text("/app/config.json", '{"debug": true}', { mode: 0o644 })
.copyFile("./cert.pem", "/etc/ssl/cert.pem")
.append("/etc/hosts", "127.0.0.1 myapp.local\n"),
)
.create();
from microsandbox import Patch, Sandbox
sb = await Sandbox.create(
"worker",
image="alpine",
patches=[
Patch.text("/etc/greeting.txt", "Hello from a patched rootfs!\n"),
Patch.text("/etc/motd", "Custom message of the day.\n", replace=True),
Patch.mkdir("/app", mode=0o755),
Patch.text("/app/config.json", '{"debug": true}', mode=0o644),
Patch.copy_file("./cert.pem", "/etc/ssl/cert.pem"),
Patch.append("/etc/hosts", "127.0.0.1 myapp.local\n"),
],
)
mode755 := uint32(0o755)
mode644 := uint32(0o644)
sb, err := m.CreateSandbox(ctx, "worker",
m.WithImage("alpine"),
m.WithPatches(
m.Patch.Text("/etc/greeting.txt", "Hello from a patched rootfs!\n", m.PatchOptions{}),
m.Patch.Text("/etc/motd", "Custom message of the day.\n", m.PatchOptions{Replace: true}),
m.Patch.Mkdir("/app", m.PatchOptions{Mode: &mode755}),
m.Patch.Text("/app/config.json", `{"debug": true}`, m.PatchOptions{Mode: &mode644}),
m.Patch.CopyFile("./cert.pem", "/etc/ssl/cert.pem", m.PatchOptions{}),
m.Patch.Append("/etc/hosts", "127.0.0.1 myapp.local\n"),
),
)
The patch builder appends operations in the order you call them; calls are chainable. Available operations across SDKs: text, file, mkdir, append, copyFile / copy_file, copyDir / copy_dir, symlink, remove.
For per-language signatures and option shapes, see the SDK references:
PatchBuilderPatchBuilderPatch (factory) and PatchConfig (the value type)Patch (helpers) and PatchConfig (the value type)By default the microsandbox agent runs as PID 1 inside the guest: small, fast, minimal. For workloads that expect a real init (systemd, OpenRC, s6, runit, etc.), --init hands PID 1 over to the init binary of your choice.
Common reasons to opt in: long-lived daemons, system service tests, anything that talks to dbus or expects systemctl to work.
Use auto to pick a known init binary from the image, or pass an absolute path when you need to pin the entry point for reproducible CI.
let sb = Sandbox::builder("worker") .image("ghcr.io/superradcompany/debian-systemd:12") .memory(1024) .cpus(2) .init("auto") .create() .await?;
```typescript TypeScript
import { MiB, Sandbox } from "microsandbox";
await using sb = await Sandbox.builder("worker")
.image("ghcr.io/superradcompany/debian-systemd:12")
.memory(MiB(1024))
.cpus(2)
.init("auto")
.create();
from microsandbox import Sandbox
sb = await Sandbox.create(
"worker",
image="ghcr.io/superradcompany/debian-systemd:12",
memory=1024,
cpus=2,
init="auto",
)
sb, err := m.CreateSandbox(ctx, "worker",
m.WithImage("ghcr.io/superradcompany/debian-systemd:12"),
m.WithMemory(1024),
m.WithCPUs(2),
m.WithInit(m.Init.Auto()),
)
msb run ghcr.io/superradcompany/debian-systemd:12 \
-m 1G -c 2 \
--init auto \
-- bash
To verify the handoff worked, check /proc/1/comm inside the sandbox:
$ msb run ghcr.io/superradcompany/debian-systemd:12 --init auto -- cat /proc/1/comm
systemd
If --init=auto cannot find an init binary, boot fails with a clear error in kernel.log. Use an explicit path when you know where the init lives.
Pass extra argv and env to the init:
init_with(...) / initWith(...).InitConfig to init=.--init-arg once per entry, and --init-env KEY=VAL once per env var.argv[0] is the init command; extra argv entries are appended after it. Env is merged on top of the inherited environment.
await using sb = await Sandbox.builder("worker")
.image("ghcr.io/superradcompany/debian-systemd:12")
.initWith("/lib/systemd/systemd", (i) => i
.args(["--unit=multi-user.target"])
.env("container", "microsandbox"))
.create();
from microsandbox import InitConfig, Sandbox
sb = await Sandbox.create(
"worker",
image="ghcr.io/superradcompany/debian-systemd:12",
init=InitConfig(
cmd="/lib/systemd/systemd",
args=("--unit=multi-user.target",),
env={"container": "microsandbox"},
),
)
sb, err := m.CreateSandbox(ctx, "worker",
m.WithImage("ghcr.io/superradcompany/debian-systemd:12"),
m.WithInit(m.Init.Cmd("/lib/systemd/systemd",
m.InitOptions{
Args: []string{"--unit=multi-user.target"},
Env: map[string]string{"container": "microsandbox"},
},
)),
)
msb run ghcr.io/superradcompany/debian-systemd:12 \
--init /lib/systemd/systemd \
--init-arg --unit=multi-user.target \
--init-env container=microsandbox \
-- bash
Most slim Docker base images do not include systemd or another full init. Use an image that ships the init you want, or build a small custom image that installs it.
--init controls PID 1. --entrypoint and the trailing command control the workload you run after boot, so the two can be combined.