boxer

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:

lockfileimage
Gemfile.lockruby:3-slim-bookworm
pom.xmlmaven:3-eclipse-temurin-21
build.gradle, build.gradle.ktsgradle:8-jdk21
bun.lock, bun.lockboven/bun:1-slim
pnpm-lock.yaml, package-lock.json, yarn.locknode:24-bookworm-slim
uv.lock, requirements.txt, pyproject.tomlpython:3.12-slim-bookworm
Cargo.lockrust:1-slim-bookworm
go.sum, go.modgolang:1-alpine
none of thesedebian: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:

imageboxer upfirst pack build
alpine:3.210.6s8.6s
node:24-alpine1.7s10.9s
node:24-bookworm-slim2.5s13.4s
node:24-bookworm9.5s26.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 no clippy or rustfmt: add them with rustup component add in image_setup, with static.rust-lang.org in allow_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 stdin

Docker 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:

keyrunshow oftenuse it for
image_setupin the guest, as rootonce per sandbox. On smolvm, once per host, because the result goes into the environment packsystem packages, global tools, compilers
setupin the guest, in the mounted worktreeonce per worktree, and again after the list changesnpm ci, uv sync, anything that writes into the worktree
[prep] commandson the host, in the worktreeonce per worktree, and again after the list changesinstalling 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 worktree

setup 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, go

Install 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:

devcontainerboxer
onCreateCommandimage_setup
postCreateCommandsetup
postStartCommandstart
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 start line runs detached in the worktree, after setup, 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 restart stops the services and launches them again, without recreating the sandbox.
  • Service output, and a line for each restart, goes to /tmp/boxer-start.log. When ready does 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"]
entryhost 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"]       # vite

boxer 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:61234

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

Top-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.local is not in a new worktree. Export the values it holds in the shell that starts the agent and list them in secrets, or copy the file in with a [prep] command.
  • boxer reads env_passthrough and secrets from 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.
  • user runs setup, start and every command.
  • image_setup always runs as root.

Control the network

[network]
mode = "allowlist"
allow_hosts = ["registry.npmjs.org"]
modeallows
"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.

On this page