Environment
Choose an image, install dependencies with image_setup, setup or prep, run services, forward ports, and set environment and network.
Set up what runs in each sandbox with keys in boxer.toml.
image = "mirror.gcr.io/library/node:24-alpine"
image_setup = ["apk add --no-cache git"]
setup = ["npm ci"]
start = ["npx next dev --webpack -p 3000 -H 0.0.0.0"]
ready = "node -e \"fetch('http://127.0.0.1:3000/').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))\""
[network]
allow_hosts = ["registry.npmjs.org"]
ports = ["auto:3000"]The configuration reference lists every key and its default.
Choose an image
image = "node:24-bookworm-slim"Any OCI reference works. Without image, boxer picks one from the worktree's lockfile:
| lockfile | image |
|---|---|
Gemfile.lock | ruby:3-slim-bookworm |
pom.xml | maven:3-eclipse-temurin-21 |
build.gradle, build.gradle.kts | gradle:8-jdk21 |
bun.lock, bun.lockb | oven/bun:1-slim |
pnpm-lock.yaml, package-lock.json, yarn.lock | node:24-bookworm-slim |
uv.lock, requirements.txt, pyproject.toml | python:3.12-slim-bookworm |
Cargo.lock | rust:1-slim-bookworm |
go.sum, go.mod | golang:1-alpine |
| none of these | debian:bookworm-slim |
boxer looks only in the worktree's root, and the first file in the table's order wins. A project
with two languages gets the first one's image: a Django app with a package-lock.json for its
frontend gets Node, with no Python. Name the image yourself and install the other language:
image = "mirror.gcr.io/library/python:3.12-slim-bookworm"
image_setup = ["apt-get update && apt-get install -y --no-install-recommends nodejs npm"]
setup = ["pip install -r requirements.txt", "cd frontend && npm ci", "python manage.py migrate"]setup lines run in order, each from the worktree root. What they install lands in the worktree
and is built for the sandbox's Linux: a node_modules with native modules will not run on your
Mac, so running npm on the host in the same worktree needs npm rebuild there first, and the
next setup undoes it. A file git does not track, such as
db.sqlite3 or .env, is not in a new worktree, so create it in setup.
Ruby and JVM lockfiles win over a package.json kept for assets. The default intercept list
covers Node, Python, Rust, Go and make. For Ruby or Java, add your tools with intercept_also,
which keeps the default list: intercept_also = ["ruby", "bundle", "rails", "rake", "rspec"], or
["gradle", "gradlew", "mvn", "mvnw", "java"]. ./gradlew and bin/rails match by name.
boxer doctor prints the line for your project.
pnpm needs corepack enable in image_setup, since the node image does not ship it enabled.
A smaller image starts faster. A microVM attaches the image's filesystem on every boxer up.
Measured on an M4 with the pack already built:
| image | boxer up | first pack build |
|---|---|---|
alpine:3.21 | 0.6s | 8.6s |
node:24-alpine | 1.7s | 10.9s |
node:24-bookworm-slim | 2.5s | 13.4s |
node:24-bookworm | 9.5s | 26.2s |
- Alpine uses musl, not glibc. A dependency that ships only a glibc prebuilt fails there.
- The Node, Python and Ruby slim images have no compiler. If a dependency builds from source,
install the toolchain in
image_setup. You do not need the full image. The Rust and Go images can build, but Rust's has noclippyorrustfmt: add them withrustup component addinimage_setup, withstatic.rust-lang.orginallow_hosts.
You can also use a local image. boxer pulls nothing for it, so you do not need to allow a registry host:
image = "./dev.tar" # a docker save archive
image = "./rootfs" # an extracted rootfs directory
image = "-" # read from stdinDocker Hub allows 100 anonymous pulls an hour. Several sandboxes can use that up. Pull public
images through mirror.gcr.io/library/, which has no quota.
Install dependencies
Three keys install things:
| key | runs | how often | use it for |
|---|---|---|---|
image_setup | in the guest, as root | once per sandbox. On smolvm, once per host, because the result goes into the environment pack | system packages, global tools, compilers |
setup | in the guest, in the mounted worktree | once per worktree, and again after the list changes | npm ci, uv sync, anything that writes into the worktree |
[prep] commands | on the host, in the worktree | once per worktree, and again after the list changes | installing packages that ship prebuilt binaries, faster than the guest can |
image_setup = ["npm i -g typescript"] # changes the guest image
setup = ["npm ci"] # writes node_modules into this worktreesetup must install into the worktree. boxer records that it ran on the host, with the worktree,
so a recreated sandbox does not run it again. Anything it installs elsewhere, such as a system-wide
pip install, is gone after the sandbox is recreated or reclaimed. Install Python packages into a
virtual environment in the worktree, and system packages in image_setup:
setup = ["python -m venv .venv && .venv/bin/pip install -r requirements.txt"]Do not put npm ci in image_setup. The pack records it as done, but node_modules lives in the
worktree and the pack does not carry it. The next worktree starts with no dependencies.
Run boxer up --rebuild when something outside these keys changed, such as a base image tag that
moved. It recreates the sandbox and drops its pack, if it has one, along with the setup record.
Both run again.
Host package caches
boxer can mount the host's package cache into the guest, read-only, to save downloads. It is off
by default. It picks the cache by lockfile: ~/.npm for package-lock.json, and the pnpm, yarn,
bun, uv, poetry, cargo and Go caches for theirs. The install still runs in the guest, so native
binaries are built for the guest.
The mount is read-only and sits where the tool writes its cache. A clean install of a lockfile the
host has already installed works. Installing anything the host has not cached fails, for example
npm install <new-package> with EROFS: read-only file system. Turn it on only for projects
whose agents do not add dependencies.
[cache]
enabled = true # off by default
managers = ["auto"] # or a list: npm, pnpm, yarn, bun, uv, poetry, cargo, goInstall on the host with prep
[prep] runs on the host while the sandbox boots. boxer waits for it before setup starts. It
matches devcontainer's initializeCommand.
$BOXER_TARGET_FLAGS carries the guest's platform, so npm, pnpm and bun fetch the Linux binaries:
[prep]
commands = ["npm ci $BOXER_TARGET_FLAGS"] # --os=linux --cpu=arm64 --libc=musl on alpine, Apple Silicon
target = "auto" # the default; or "none", or "linux/arm64/musl"A package that compiles at install time, such as better-sqlite3 or node-pty, builds for the host
whatever the flags say. It then fails in the guest with invalid ELF header. Install those projects
with setup.
boxer up and boxer doctor warn when package-lock.json has one of the common ones. The list is
not complete. examples/host-prep is a working config.
From a devcontainer
If the repository has .devcontainer/devcontainer.json, boxer reads it first:
| devcontainer | boxer |
|---|---|
onCreateCommand | image_setup |
postCreateCommand | setup |
postStartCommand | start |
initializeCommand | [prep] |
boxer also reads the image or Dockerfile, ports, environment, bind mounts and remoteUser. It does
not run Compose or install features, so dockerComposeFile and features are ignored, with a warning that names them. Bring your Docker setup has what to do
instead. Any key in boxer.toml overrides the devcontainer.
The configuration reference has the full mapping.
You still need a short boxer.toml for the allowlist. devcontainer has no key for it, and boxer's
default network reaches only the image registry.
Run services
Use start for long-running processes. Use ready for a command boxer polls until it exits 0.
start = ["npm run dev"]
ready = "curl -sf http://127.0.0.1:3000/ >/dev/null"
ready_timeout = "120s" # the default is 60s- Each
startline runs detached in the worktree, aftersetup, every time the sandbox starts. The lines are launched in order, without waiting for each other. A server that needs the database must retry until it answers, or wait for it in its own line:until pg_isready -h 127.0.0.1; do sleep 1; done; exec uvicorn main:app. - A process that crashes is restarted after 1 second, then 2, 4 and up to 30. After five quick
crashes in a row boxer stops trying. A process that exits 0 is not restarted. Set
restart = "never"to turn this off. boxer restartstops the services and launches them again, without recreating the sandbox.- Service output, and a line for each restart, goes to
/tmp/boxer-start.log. Whenreadydoes not pass in time, boxer prints the end of that log. - To keep a service's data when the sandbox is recreated or reclaimed, put it in a volume. Run Postgres, Redis and other services has an example.
Forward ports
[network]
ports = ["auto:3000"]| entry | host port |
|---|---|
"auto:3000" | a free host port, forwarded to 3000 in the guest |
"8080:3000" | always 8080. A second worktree then fails to start. |
Use auto: so worktrees do not collide. Forwards bind 127.0.0.1 on the host. Ports are fixed when the
sandbox is created, so run boxer up --recreate after you change the list.
The server must listen on 0.0.0.0 in the guest. A forward arrives on the guest's network
interface, so a server bound to 127.0.0.1 never sees it. A ready probe in the guest does not
catch this.
start = ["npx next dev --webpack -H 0.0.0.0"] # next
start = ["npx vite --host 0.0.0.0"] # viteboxer status prints where each guest port landed:
$ boxer status
boxer: swift-crab (sb-7e1852e4a3c3) running, image node:24-bookworm-slim, mounted /Users/me/src/myapp -> /workspace
port: guest 3000 -> http://127.0.0.1:61234Next.js 15 warns about every dev chunk served through a forwarded port, and says a future version
will refuse them. Add this to next.config.ts:
allowedDevOrigins: ["127.0.0.1", "localhost", "*.localhost"]To give each worktree's server a name instead of a port number, turn on
[urls].
Set environment variables and secrets
env_passthrough = ["CI"] # copied from the host on each run; the default
secrets = ["NPM_TOKEN"] # the same, and kept out of argv
[env]
NODE_ENV = "development" # stored in the packTop-level keys such as env_passthrough and secrets must come before the first [table]. TOML
puts every key after [env] into that table, and boxer then refuses to load the file.
[env]is part of the pack key. Put nothing secret there.- A gitignored
.env.localis not in a new worktree. Export the values it holds in the shell that starts the agent and list them insecrets, or copy the file in with a[prep]command. - boxer reads
env_passthroughandsecretsfrom the host every time.
Security explains how secrets reach the guest.
Add mounts and choose the user
mounts = ["~/datasets:/data:ro"] # host:guest, or host:guest:ro
user = "node" # a name or uid[:gid]; the default is the image's USER- The worktree is always mounted at
mount_at, which defaults to/workspace. userrunssetup,startand every command.image_setupalways runs as root.
Control the network
[network]
mode = "allowlist"
allow_hosts = ["registry.npmjs.org"]mode | allows |
|---|---|
"allowlist" (default) | the image registry and the hosts in allow_hosts |
"on" | everything |
"off" | nothing |
allow_hosts takes exact hostnames, addresses, CIDR blocks (10.20.0.0/16) and address ranges
(192.168.1.10-192.168.1.20), for a private registry or a service on your network. Wildcards and
ports are refused. See allow_hosts.
Only smolvm enforces allowlist. docker, podman and Apple container refuse it, so set "on" or
"off" there. See Backends.
When an install fails because a host is missing, boxer's error names the host.