Introducing Box

January 4, 2026

Package managers are my favorite part of Linux. Having a consistent, curated way to install and upgrade your system makes everything easier and more secure. That’s not to say there can’t still be security issues, but if you can’t generally trust the system packages you probably have bigger issues, and it beats downloading random binaries off the internet.

Unfortunately (and ironically) this system starts to fall apart when it comes to software development, especially at work where you may have less control over which tools and dependencies are used. While most Linux distributions include a wide array of programming tools, there are often weird requirements for specific versions or third-party tooling which may not be packaged. And although we can assume that the language tooling itself is secure if it comes from the package manager, the software we build and run with it may have vulnerable or even malicious dependencies that could wreak havoc on our system.

A common way to ameliorate these issues is to use containers for development. Although they aren’t true sandboxes, containers can add additional layers of protection by preventing binaries from accessing parts of your system they shouldn’t (i.e. most of your home directory), and since they don’t share a root filesystem with the host, they can run arbitrary versions of tools without interference. Programs like docker compose are often used to automate running a set of services for development (e.g. PostgreSQL), but using containers for running CLI tools (e.g. go build) has significantly worse user experience than installing and running the tools directly.

The generally accepted solution to this appears to be devcontainers, which installs and executes all the required tools for a project inside a semi-persistent development environment. This is a nice idea in theory, as new team members or contributors can just spin up a container with everything preconfigured, but since the development environments need to be codified, it works best when everyone uses the same tooling and workflows (even code editors - devcontainers are rather tightly coupled with Visual Studio Code). What I want is the opposite: rely on my system’s package manager as much as possible, and only run specific tools in containers based on security or versioning concerns.

Enter box

My solution is box: a CLI for transparently running your development tools in a container, with the goal of combining the security and versioning benefits of containers with the ease of running tools directly on the host. box (ab)uses your PATH to replace your development tools with symlinks to itself, then executes the corresponding command in a container. As a result, you can continue using the tool as if it were natively installed, including in scripts or in your editor (e.g. Neovim’s language server integration).

box is essentially a wrapper around a container runtime (currently Docker and Podman are supported). It is configured via environment variables, which map loosely to the runtime’s directives, e.g. BOX_ENVS="LOG_LEVEL=debug HTTP_PORT=8080". This configuration method was chosen primarily because it allows for nested configs and overrides out-of-the-box, since environment variables can be composed from other environment variables (e.g. export BOX_ENVS="$BOX_ENVS HTTP_HOST=0.0.0.0"). As an added benefit, it also integrates very well with tools that manage environment variables, such as direnv.

A Case box Study

At work I am writing a Rust-based firmware for an ESP32-S3 microcontroller. This device has an Xtensa microprocessor, which is not an officially supported Rust target, and as such requires a forked toolchain maintained by Espressif (the makers of the ESP platform). In this case, we can use box to run everything safely in a container.

First we’ll need to set our runtime; since this will probably be the same for all projects, I like to set this in my .profile:

export BOX_RUNTIME=podman

Next we’ll need an image from which to create a container. Espressif publishes an image with the toolchain already included, but we’ll need to add some extra tools:

FROM espressif/idf-rust:esp32s3_1.88.0.0

RUN rm ~/.cargo/bin/rust-analyzer && \
	curl -sL https://github.com/rust-lang/rust-analyzer/releases/download/2025-08-25/rust-analyzer-x86_64-unknown-linux-gnu.gz | gunzip -c - > ~/.cargo/bin/rust-analyzer && \
	chmod +x ~/.cargo/bin/rust-analyzer

RUN rm ~/.cargo/bin/cargo-espflash ~/.cargo/bin/espflash && \
	curl -sL https://github.com/esp-rs/espflash/releases/download/v4.3.0/cargo-espflash-x86_64-unknown-linux-gnu.zip | gunzip -c - >~/.cargo/bin/cargo-espflash && \
	curl -sL https://github.com/esp-rs/espflash/releases/download/v4.3.0/espflash-x86_64-unknown-linux-gnu.zip | gunzip -c - >~/.cargo/bin/espflash && \
	chmod +x ~/.cargo/bin/cargo-espflash ~/.cargo/bin/espflash

COPY ./entrypoint.sh /usr/local/bin/entrypoint.sh

ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]

Here we install rust-analyzer (Rust’s language server) and a newer version of espflash (for flashing and monitoring the microcontrollers). We need to replace the rust-analyzer that is already in the image since it is installed for the default toolchain and won’t work with the ESP toolchain.

The entrypoint mentioned sources some necessary setup scripts:

#!/bin/bash -eu

source "$HOME/.cargo/env"
source "$HOME/export-esp.sh"

exec "$@"

By default the container uses /bin/bash as the CMD, but we need these scripts to be sourced for the toolchain to work.

With our image ready, we can configure box. I use direnv to automatically set the necessary environment variables when I enter the directory, but they could also be manually set or sourced from a script. Here’s my .envrc:

export BOX_IMAGE="esp"

dotenv_if_exists
envs_from_dotenv="$(envs_from_dotenv)"
export BOX_ENVS="CARGO_HOME=$HOME/.cargo $envs_from_dotenv"

export BOX_VOLUMES="$HOME/.cargo/git:$HOME/.cargo/git $HOME/.cargo/registry:$HOME/.cargo/registry $PWD:$PWD"
export BOX_WORKDIR="$PWD"
export BOX_EXTRA_OPTS="--userns=keep-id --cap-drop=ALL --read-only"

serial_extra_opts='--group-add=keep-groups --device=/dev/ttyACM0'
export BOX_cargo_run_EXTRA_OPTS="$BOX_EXTRA_OPTS $serial_extra_opts"
export BOX_espflash_monitor_EXTRA_OPTS="$BOX_EXTRA_OPTS $serial_extra_opts"

export BOX_esptool_MATCH='esptool.py'
export BOX_esptool_IMAGE=espressif/idf:release-v5.3
export BOX_esptool_EXTRA_OPTS="$BOX_EXTRA_OPTS $serial_extra_opts"

There’s a lot going on here so we’ll break it down by section:

export BOX_IMAGE="esp"

First we set the image we’ll use to run our commands (in this case, the image I described above, built with the tag esp).

dotenv_if_exists
envs_from_dotenv="$(envs_from_dotenv)"
export BOX_ENVS="CARGO_HOME=$HOME/.cargo $envs_from_dotenv"

This section automatically loads a .env file if it exists, and specifies some environment variables that should be set in the container. The envs_from_dotenv is a custom direnv directive that will automatically export variables loaded from the .env into the container; this is a great way to include secrets without having them be part of the .envrc directly.

export BOX_VOLUMES="$HOME/.cargo/git:$HOME/.cargo/git $HOME/.cargo/registry:$HOME/.cargo/registry $PWD:$PWD"
export BOX_WORKDIR="$PWD"

Next we set some bind-mounts so the container has access to certain paths on the host. The first two entries mount our cargo cache so we don’t need to redownload dependencies every time we run a command (this is also where the CARGO_HOME environment variable comes into play). We also mount the current working directory to the same path in the container so cargo can access our project, and set the container’s working directory correspondingly. Mounting the current directory to the same path in the container also means we can run rust-analyzer inside the container and have jump-to-definition work correctly.

export BOX_EXTRA_OPTS="--userns=keep-id --cap-drop=ALL --read-only"

The BOX_EXTRA_OPTS directive is an escape hatch for specifying any arbitrary Docker/Podman args that aren’t represented by another directive. In my case I want to make sure that the user in the container has the same uid as my host user, so the file permissions match up. The capability drop and read-only flags are an extra bit of security hardening.

serial_extra_opts='--group-add=keep-groups --device=/dev/ttyACM0'
export BOX_cargo_run_EXTRA_OPTS="$BOX_EXTRA_OPTS $serial_extra_opts"
export BOX_espflash_monitor_EXTRA_OPTS="$BOX_EXTRA_OPTS $serial_extra_opts"

It’s very likely that not all tools will require the same resources; case in point, when we want to flash the firmware to an actual ESP, we’ll need access to the serial port from the host. While we could set these flags globally, they’re really only necessary for two commands: cargo run (which builds and flashes the code) and espflash monitor (which monitors the logs of the device over the serial port). box allows us to specify overrides for specific commands so we can tailor-make the container with exactly the resources it needs. By using BOX_cargo_run_EXTRA_OPTS, we will only set these additional parameters for the cargo run command, but not for other commands (e.g. cargo check).

export BOX_esptool_MATCH='esptool.py'
export BOX_esptool_IMAGE=espressif/idf:release-v5.3
export BOX_esptool_EXTRA_OPTS="$BOX_EXTRA_OPTS $serial_extra_opts"

The last block adds overrides for esptool, which is a Python-based tool that has some additional functionality currently missing from espflash. What’s neat here is that we can even override the image on a per-tool basis, so you don’t necessarily need to create one giant image with all the tooling. The BOX_esptool_MATCH directive also shows off how to create overrides that are not space-delimited: it maps the command esptool.py to the esptool override; any directives with the same override will be applied.

Finally, we need to create symlinks for the tools we want to run with box. We can use the box alias add <alias>... subcommand to automatically create them:

$ box alias add cargo espflash esptool rust-analyzer

This will create symlinks to box in the XDG_DATA_HOME/box directory (typically ~/.local/share/box on most Linux systems), but this can be customized using the BOX_PATH directive. This path also needs to be added to your PATH, e.g. in your .profile:

export PATH="$HOME/.local/share/box:$PATH"

With all that in place, we can now run cargo build normally in our terminal, but have it magically executed in a container, safely separated from our host system!

Try It Out!

You can find box’s code on Codeberg, along with additional documentation on all the available directives. If you use direnv you’ll also want to check out the included library script to make it even easier to integrate. Currently the easiest way to install box is with cargo install --git https://codeberg.org/galen/box-cli (or by cloning and cargo building manually).

Thanks for reading, and I look forward to hearing about your experience with box!