Package Managers
boks supports these package manager providers via the -e flag. The
provider is always explicit now — -e @pip:requirements.txt, never a bare
-e requirements.txt — so there's no ambiguity between providers that
happen to share a filename convention (@uv/@poetry both reading
pyproject.toml, for instance).
Python
| Provider | Trigger | Default file |
|---|---|---|
@pip | -e @pip or -e @pip:file | requirements.txt |
@uv | -e @uv or -e @uv:file | requirements.txt, pyproject.toml |
@poetry | -e @poetry or -e @poetry:file | pyproject.toml |
Examples
boks -e @pip:requirements.txt python script.py
boks -e @pip python script.py # auto-finds requirements.txt
boks -e @uv:requirements.txt python script.py
boks -e @uv python script.py # auto-finds requirements.txt or pyproject.toml
boks -e @poetry:pyproject.toml python script.py
boks -e @poetry python:requests,click script.py # inline: synthesizes a throwaway pyproject.toml
Packages install directly into the base Python (poetry config virtualenvs.create false) rather than a nested venv — a boks env already
is the isolated environment, so poetry's own venv layer would be redundant.
Package-specific rules
Some packages need more than just installing — they want a host-side cache
persisted, or a runtime capability auto-enabled. boks handles both with the
same mechanism, keyed on the package rather than the tool itself (the same
idea as cargo/rust's ~/.cargo/registry dotfiles cache, see
Tool Index).
Dotfiles — huggingface_hub is the example: installing it (inline or via
a file) mounts a host-backed ~/.cache/huggingface, so a downloaded model or
dataset persists instead of re-downloading on every run.
boks --cap net python:huggingface_hub -c "from huggingface_hub import hf_hub_download; hf_hub_download('org/repo', 'file')"
boks python:huggingface_hub -c "from huggingface_hub import hf_hub_download; hf_hub_download('org/repo', 'file', local_files_only=True)" # offline, reads the cache
Capabilities — an HTTP-client library's whole point is to make network
calls, but boks's default is net=none. Rather than make you remember --cap net
every time, boks auto-enables network for the packages that exist to use it:
boks python:requests -c "import requests; print(requests.get('https://example.com').status_code)" # → 200, no --cap net needed
Today requests, httpx, and aiohttp under pip auto-enable net. As with
any auto-capability, an explicit CLI flag always wins — --cap net on a requests
run is a harmless no-op (already on), and any future opt-out flag would take
precedence. huggingface_hub deliberately gets only a dotfiles mount and no
auto-net: its offline-cache workflow relies on a second, un-networked run
reading the cache, so silently granting it network would defeat that test.
Detection works for both the inline form (python:requests) and file-based
installs — a requests line in requirements.txt or pyproject.toml
triggers the same rule. The mechanism itself (package_rules in the index)
is provider-scoped, so a future entry could grant the same capability for a
different language's HTTP client without affecting unrelated packages — and a
package with no rule (e.g. jinja2) stays fully isolated, exactly as if no
packages were involved.
This mapping is index-curated, not locally configurable: it lives on the
tool's global index entry (package_rules, see Tool
Index) and is deliberately constrained — dotfile paths to
~/.cache/ or ~/.config/ (the same security boundary as every other
dotfiles mount), capabilities to the same set any tool can declare — just
keyed on a package name instead of a tool name.
Conda
Not implemented.
-e @condafails withUnknown package provider: conda— there is no conda builder in boks, and everything in this section describes a design that was never shipped. Verified 2026-08-11. It is kept here as the intended design, not as documentation of working behaviour.Note also that the
--exec-asrequirement described below is a symptom of exactly the resolution-contract problem that-e @npmand-e @composerjust had fixed: needing a boks-specific flag to reach the environment means unmodified code does not work. Conda should be built so that a plainpython script.pyuses the environment's own interpreter, rather than shipping the workaround.
| Provider | Trigger | Default file |
|---|---|---|
@conda (not implemented) | -e @conda or -e @conda:file | environment.yml |
Examples
boks -e @conda:environment.yml --exec-as /opt/conda/bin/python3 python@3.12 script.py
boks -e @conda python@3.12:numpy,pandas --exec-as /opt/conda/bin/python3 script.py
Unlike every other provider here, no base image ships conda — @conda
bootstraps Miniforge (not
Anaconda's own installer: Anaconda's default channel carries a commercial-use
Terms of Service, which Miniforge's conda-forge-only default avoids entirely)
into /opt/conda on first use, then installs packages via conda install -c conda-forge (inline) or conda env update (file-based, against
environment.yml's own dependencies: list — no package-rules
detection, since it's YAML and boks has no YAML parser dependency).
--exec-as /opt/conda/bin/python3 is required to actually use conda's
packages. Miniforge installs its own Python into /opt/conda, separate
from the base image's system Python — and a tool image's ENTRYPOINT is
typically an absolute path (e.g. /usr/bin/python3 for boks.sh/ python), which bypasses $PATH lookup entirely, so the PATH prepend alone
doesn't redirect the entrypoint itself. Point --exec-as at conda's
interpreter directly to run under it.
@conda needs bash and ldd inside the build environment for Miniforge's
own installer script, which a minimal Wolfi/Alpine base doesn't ship by
default — @conda installs both via apk first when apk is available, a
harmless no-op on any other base that already has them (e.g. Debian).
Node.js
| Provider | Trigger | Default file |
|---|---|---|
@npm | -e @npm or -e @npm:file | package.json |
@yarn | -e @yarn or -e @yarn:file | package.json |
Examples
boks -e @npm:package.json node app.js
boks -e @npm node app.js # auto-finds package.json
boks -e @yarn:package.json node app.js
boks -e @yarn node app.js # auto-finds package.json, installs with yarn instead of npm
package.json alone doesn't say whether a project wants npm or yarn — name
the provider explicitly (@npm vs @yarn) either way.
R
| Provider | Trigger | Default file |
|---|---|---|
@renv | -e @renv or -e @renv:file | renv.lock |
Examples
boks -e @renv:renv.lock Rscript script.R
boks -e @renv Rscript script.R # auto-finds renv.lock
R and Rscript are two separate tools, mirroring a local install: R is
the interactive REPL (boks R launches it), Rscript runs a script file and
exits (boks Rscript script.R, like Rscript script.R on a host) — use
Rscript here, not R. The tool name is capital R/Rscript, matching the
real commands on Linux (there's no lowercase r). Alpine ships a single
current R release, so there's no per-minor-version axis the way Python/Node
have.
Ruby
| Provider | Trigger | Default file |
|---|---|---|
@gem | -e @gem or -e @gem:file | Gemfile |
Examples
boks -e @gem:Gemfile ruby app.rb
boks -e @gem ruby app.rb # auto-finds GemfilePHP
| Provider | Trigger | Default file |
|---|---|---|
@composer | -e @composer or -e @composer:file | composer.json |
Examples
boks -e @composer:composer.json php app.php
boks -e @composer php app.php # auto-finds composer.jsonElixir
| Provider | Trigger | Default file |
|---|---|---|
@hex | -e @hex or -e @hex:file | mix.exs |
Examples
boks -e @hex:mix.exs elixir script.exs
boks -e @hex elixir script.exs # auto-finds mix.exs
Hex has no pip install <pkg>-style equivalent for arbitrary packages, so an
inline install (boks elixir:jason) synthesizes a minimal mix.exs behind
the scenes.
Dependencies are discovered automatically, like pip's site-packages: the
provider sets ERL_LIBS, which the Erlang code server reads at startup, so
an ordinary script works with no boilerplate.
$ cat idiomatic.exs
IO.puts("hex idiomatic: " <> Jason.encode!(%{ok: true}))
$ boks -e @hex elixir idiomatic.exs
hex idiomatic: {"ok":true}
Correction (2026-08-11): this page previously said a plain
elixirinvocation "has no automatic way to discover compiled dependencies" and pointed at aCode.prepend_pathincantation. That was wrong — verified live, both with the script above and with a control run that correctly fails without-e @hex.boks-examples/elixir-hexstill carries the unnecessary boilerplate.
Lua
| Provider | Trigger | Default file |
|---|---|---|
@luarocks | -e @luarocks:<file>.rockspec | none (no default filename) |
Examples
boks -e @luarocks:myproject-0.1-1.rockspec lua script.lua
boks lua:dkjson -e 'print(require("dkjson").encode({ok = true}))'
@luarocks has no default-file auto-detection (-e @luarocks alone has no
.rockspec to fall back to): rockspecs are named
<package>-<version>-<revision>.rockspec by convention, not a single
canonical filename like Gemfile/composer.json/mix.exs, so the file
must always be passed explicitly alongside the provider. Unlike Hex/Composer, no boilerplate is
needed in the script itself — the provider sets LUA_PATH/LUA_CPATH at
image-build time, which Lua's runtime reads automatically at startup, the
same mechanism as Python's PYTHONPATH.
Ansible
| Provider | Trigger | Default file |
|---|---|---|
@galaxy | -e @galaxy or -e @galaxy:file | requirements.yml |
Examples
boks -e @galaxy:requirements.yml ansible-playbook -- playbook.yml
boks -e @galaxy ansible-playbook -- playbook.yml # auto-finds requirements.yml
boks ansible-playbook:community.general -- playbook.yml # inline, no manifest
The galaxy provider installs ansible-galaxy collections into the env image
at a fixed path (/opt/ansible/collections) and sets ANSIBLE_COLLECTIONS_PATH
to find them — so a playbook uses community.general.* modules with no
per-playbook setup, no --cap net, and a read-only rootfs. Like @luarocks,
discovery is automatic (ansible reads the env var at startup, like Lua reads
LUA_PATH); unlike terraform (whose providers are project-local to the
working tree), collections can be baked into an image layer and scanned before
any playbook runs.
ansible-playbook is a first-class tool name (a separate index entry sharing
the ansible image, bin=/usr/bin/ansible-playbook) — the boks <cmd> prefix
reaches it directly, mirroring ansible-playbook on a host. There's also a
matching ansible-galaxy entry for ad-hoc boks ansible-galaxy collection install … outside the provider. See boks-examples/ansible-galaxy for the
full walkthrough, including the two tmp/path gotchas the provider handles
invisibly.
Running the paired command in the same environment
pip and npm are also runnable as bare tool names — boks pip and boks npm
resolve to the exact same image as boks python and boks node respectively
(same digest), so building via one and then invoking the
other against the same environment file reuses the identical cached image
instead of building a second one:
boks -e @pip:requirements.txt python@3.12 script.py # builds the env
boks -e @pip:requirements.txt pip@3.12 list # reuses it — same cache tag
This works for the inline-package form too (python:requests and
pip:requests share a cache). The same hand-duplicated-image pattern also
pairs R/Rscript (the REPL and the script runner — two different tools,
see the R section above) and cargo/rustc, each sharing one image under a
different bin. boks --search pip/boks --search Rscript/boks --search rustc show them like any other tool.
Running a package-installed command with --exec-as
pip/npm cover the two paired commands every language ships. For anything
else a package installs — a formatter, a linter, a CLI added via
requirements.txt/package.json — use --exec-as to run it inside the same
resolved environment instead:
boks -e @pip:requirements.txt --exec-as black python@3.12 -- --check .
This runs black (installed via requirements.txt) inside the python@3.12
environment, rather than python itself. --exec-as takes one executable,
not a shell command line (no spaces) — the tool's own arguments go after the
tool name as usual.
Auto-detection
There is no bare -e . — the provider is always named explicitly. What
auto-detects is the file: -e @provider:. (or just -e @provider, same
thing) scans the current directory for that provider's own default filename:
boks -e @pip:. python script.py # finds requirements.txt
boks -e @npm:. node app.js # finds package.json
boks -e @gem:. ruby app.rb # finds Gemfile
boks -e @composer:. php app.php # finds composer.json
boks -e @renv:. Rscript script.R # finds renv.lock
boks -e @hex:. elixir script.exs # finds mix.exs
boks -e @galaxy:. ansible-playbook -- playbook.yml # finds requirements.yml
boks -e @conda:. --exec-as /opt/conda/bin/python3 python script.py # finds environment.yml
This is a deliberate change from earlier versions, which let a bare -e .
guess the provider from whichever file it found — that guess was ambiguous
exactly where two providers share a filename convention (@uv/@poetry
both read pyproject.toml; a yarn.lock alone doesn't distinguish @npm
from @yarn), and silently wrong the rest of the time is worse than an
extra few characters typed. Naming the provider is now required everywhere,
@luarocks included — it has no default filename to fall back to at all
(see its section above), so -e @luarocks:. isn't meaningful; the
.rockspec path must always be given.