Container Config Reference ([container])¶
A repo customizes its dev container through the [container] section of its
vergil.toml. The section is optional; every key defaults to empty, so a repo
that omits [container] behaves exactly as it would with an empty table.
The keys describe the same container from a few angles: env-prefixes controls
which host environment variables vrg-container-run forwards into the
container, system-packages declares Debian packages installed inside it, and
build-command runs a repo-declared provisioning step when the cached dev image
is built (with build-cache-files naming the inputs that invalidate the cache).
Structure¶
[container]
env-prefixes = ["MQ_"] # forward matching host env vars
system-packages = ["lilypond"] # install Debian packages
build-command = "npm install -g @coderline/alphatab" # provision non-apt deps
build-cache-files = ["package-lock.json"] # inputs that invalidate the cache
Keys¶
| Key | Type | Default | Semantics |
|---|---|---|---|
env-prefixes |
list of strings | [] |
Host env-var name prefixes vrg-container-run forwards into the container (accumulates) |
system-packages |
list of strings | [] |
Debian apt package names baked into the local cached dev image and installed on CI test jobs (see below) |
build-command |
string | (unset) | A shell command run while the cached dev image is built, to provision a non-apt dependency outside /workspace (see below) |
build-cache-files |
list of strings | [] |
Repo-relative files the build-command reads; folded into the image cache hash so a bump forces a rebuild |
env-prefixes¶
Each entry is a prefix; any host environment variable whose name starts with it
is passed through when vrg-container-run launches the container. This is how a
consuming repo gets a secret or token — held only in the host environment — into
the container without hard-coding its name in tooling.
The ConanCenter audit token does not need an env-prefixes entry. For a cpp
repo, vrg-container-run resolves it from the environment or its canonical
token file and forwards it itself (see the next section and
C++ Overview → ConanCenter Audit Token).
An existing CONAN_AUDIT_PROVIDER_TOKEN prefix is harmless but redundant.
The passthrough design is specified in
docs/specs/2026-05-25-configurable-container-env-passthrough-design.md.
Variables vrg-container-run always sets¶
Besides the forwarded variables, every container launch sets a few itself. Each
is emitted after the env-prefixes passthrough, and the container runtime
applies the last -e given for a name, so a matching prefix cannot override the
value vrg-container-run resolves:
| Variable | Default | Host override |
|---|---|---|
UV_LINK_MODE |
copy (the uv cache and /workspace are on different filesystems) |
An explicit host value wins |
PYTHONPYCACHEPREFIX |
/tmp/pycache (container-local, discarded with the container) |
A non-empty host value wins; an empty value falls back to the default |
NODE_PATH |
npm global root, only when build-command is declared (see below) |
An explicit host value wins |
UV_PYTHON, UV_PYTHON_PREFERENCE |
Python only: the resolved primary version and only-system |
None: the resolver decides |
For a Python repo, UV_PYTHON and UV_PYTHON_PREFERENCE=only-system make uv
use the image's interpreter at the resolved version, so a repo's own
python-preference cannot make uv download a managed interpreter. The cached-image
build exports the same two variables for its whole setup step (vergil-tooling
install, build-command and warmup), and they are part of the image's cache key,
so a change of resolved version rebuilds the image
(#3421).
vrg-container-docs resolves no version, so for a Python repo it sets only
UV_PYTHON_PREFERENCE=only-system, which pins uv to the docs image's own Python,
as the CI docs jobs do. If that Python does not satisfy requires-python, uv
fails the build; there is no fallback
(#3414).
For a cpp repo, vrg-container-run also forwards
CONAN_AUDIT_PROVIDER_TOKEN_CONANCENTER when it can resolve one: from the
environment if set and not blank, else from ~/.config/vergil/conan.env, else
from the conan_audit_token_path file in ~/.config/vergil/identities.toml.
The value goes to the container runtime through its environment, never its
command line. A token source that exists but is unreadable or malformed stops
the run with an error, and with no token at all nothing is forwarded (see
C++ Overview → ConanCenter Audit Token).
PYTHONPYCACHEPREFIX keeps Python bytecode (including pytest's rewritten test
modules) out of the bind-mounted repo. Parallel test workers writing
__pycache__ over the macOS-to-container mount could leave a truncated .pyc
that a later run trusted, failing with EOFError: marshal data too short on code
that was fine
(#3111). With the
prefix, bytecode is still cached and shared by every worker within a run, and any
stale .pyc already in the working tree is ignored. A host override pointing
inside the repo would put bytecode back on the mount, so keep any override
outside it.
VRG_VERSION_WINDOW: a local version window¶
The version resolver reads the language version window that vergil-containers
publishes at the repo's [dependencies].vergil ref, over the network.
VRG_VERSION_WINDOW=<path> is the one sanctioned override: it names a local
window.json to resolve from instead, for example to validate offline. The
published window is never silently replaced, and an explicit override is never
silently dropped (epic
vergil-project/.github#407).
A host path is not visible inside the container, so vrg-container-run does not
forward the variable as is. When VRG_VERSION_WINDOW is set on the host, it:
- resolves the path to an absolute one (a relative path is taken from the current directory);
- stops on the host, before any image work, if the value is empty, the path is
not an existing file, or the path contains a
:(which the-vbind syntax cannot carry). The error names the path; - bind-mounts the file read-only at
/run/vergil/version-window.json; - sets
VRG_VERSION_WINDOW=/run/vergil/version-window.jsonin the container.
The run banner shows the file in use (Window: <path> (VRG_VERSION_WINDOW)).
The host value is never forwarded, even when an env-prefixes entry such as
VRG_ matches it. When the variable is unset, nothing changes: the container
fetches the published window.
system-packages¶
A list of Debian package names — nothing more. Vergil's container model
otherwise treats a system dependency as a property of the language
(dev-python:3.14 means the same thing everywhere), which is what makes the
per-branch image cache sound. system-packages is the sanctioned escape hatch
for the case where one repo genuinely needs a binary that is not a language
package — for example a repo whose tests shell out to the LilyPond renderer
(apt-get install lilypond) — without adding anything repo-specific to the
shared base images (epic
vergil-project/.github#272).
The packages are installed with:
from the base image's existing apt sources (Debian main). The surface is deliberately narrow — names only:
- No third-party apt sources, signing keys,
add-apt-repository, or install scripts. - No version pins, build flags, or post-install steps.
Where the packages take effect¶
The same declaration is applied by two different paths, which agree on what is installed while differing on when:
- Locally the packages are baked into the per-branch cached dev image that
vrg-container-runbuilds over the base (container_cache.py), so they are present for every check in that one image. - In CI they are installed by a composite setup step only on the jobs that run the repo's own tests — never on lint or typecheck (composite action added in vergil-actions#807).
Test-runtime contract. A declared system package is a test-runtime dependency. Because it is test-only in CI, repos must not rely on a system package during lint or typecheck. The local one-image convenience (visible to every check) does not license depending on it outside test jobs; the divergence is deliberate, sound because these binaries are genuinely needed only at test time. See CI Architecture → Repo-specific system packages for the mechanism in full.
Fail-closed on the build architecture¶
The cache layer builds on the host architecture (Apple Silicon → linux/arm64;
the CI runner's arch). If a declared package has no installation candidate
for that architecture, the build and the CI setup step both fail immediately,
naming the package and the architecture:
There is no silent skip and no degraded image — a missing dependency stops the build rather than producing a container that fails mysteriously inside a test.
Cache key¶
vergil.toml is already part of the cache-sensitive file set, so editing
system-packages changes the per-branch image's cache hash and forces a rebuild.
No special-casing is needed, and an empty or absent list is byte-identical to a
repo that declares no packages at all.
Trust model¶
A repo can express Debian package names only, installed from the base image's
existing sources, as root — exactly what the base image already grants. It cannot
fetch arbitrary URLs or run arbitrary code. Adding to system-packages is a
vergil.toml change reviewed like any config change, via the PR diff: the
list is a plain, greppable set of names. There is no separate allowlist — the
constrained surface plus the fail-closed build is the backstop for typos and
nonexistent names.
Inspecting the declared list¶
vrg-container-system-packages reads the key through the same single accessor
the local bake and CI both use (container_system_packages in
config.py):
vrg-container-system-packages # the declared names, one per line
vrg-container-system-packages --install-script # the exact apt snippet CI runs
The plain-list mode is a human-facing inspection affordance ("what would this
repo install?"); --install-script emits the single, shared apt speller that
both the local image build and the CI step execute verbatim.
build-command¶
A shell command run once while the per-branch cached dev image is built (after the
system-packages apt step and the vergil-tooling install, before the language
warmup). It is the escape hatch for a dependency that is not an apt package and
not a language package the base image already carries — for example a JS
library a test driver consumes at runtime (epic
vergil-project/.github#291).
The out-of-workspace contract¶
vrg-container-run bind-mounts the repo over /workspace at run time, which
masks anything the build wrote under /workspace. So a build-command must
install its artifact outside /workspace (a global/system location baked into
the image), or the run-time mount hides it. A global install is the usual way to
land outside the workspace — e.g. npm install -g <pkg> writes to
/usr/lib/node_modules, which is image-resident and survives the mount.
Landing the artifact outside /workspace makes it present, but present is
not the same as resolvable, and the two differ by artifact kind:
- Executables are on
PATH. A globally-installed CLI (npm install -gof a package with abin, apip installconsole script, a Go binary in/usr/local/bin) is onPATHand runs as-is — no extra configuration. - Libraries are not automatically on the language's module-resolution path.
This is the correction to the earlier contract wording: a global
npm install -gof a library is not "on the default resolution path." A global npm install puts executables onPATHbut does not add the npm global root (/usr/lib/node_modules) to Node'srequiresearch path. Without help,require("<lib>")/require.resolve("<lib>")returnsMODULE_NOT_FOUNDeven though the package is baked into the image (validated in vergil-project/vergil-tooling#2766; this also corrects the design spec's §4, tracked for the epic doc-sweep #2756).
NODE_PATH for baked npm libraries¶
To make a baked npm library resolvable, vrg-container-run sets NODE_PATH
to the npm global root (/usr/lib/node_modules, i.e. npm root -g on the vergil
base images) whenever the repo declares a build-command, so
require.resolve("<lib>") resolves out of the box. A repo that declares no
build-command is unaffected — no NODE_PATH is set, and its container
environment is byte-for-byte unchanged.
Two caveats:
- CommonJS only.
NODE_PATHis honoured by CommonJSrequire; ESMimportignores it. A library consumed via ESMimportneeds a different mechanism (e.g. a repo-localnode_modulespopulated bynpm ci, or staging the module into a directory ESM resolution walks up to). - Overridable. An explicit
NODE_PATHin the host environment wins, so a consumer can point resolution at a different location at run time.
The value rides the container run invocation rather than being baked into the
image with commit --change ENV, because the nerdctl runtime's commit --change
supports only CMD/ENTRYPOINT directives (not ENV); setting it on the run
path works identically across the docker and nerdctl runtimes.
Cache key¶
Like system-packages, editing build-command changes vergil.toml, which is in
the cache-sensitive file set, so a change forces an image rebuild. When the command
reads a lockfile whose contents (not the command string) determine what gets
installed, list that lockfile in build-cache-files so a dependency bump also
invalidates the cache.
Trust model¶
build-command is a deliberate broadening of the surface system-packages
grants. Where system-packages is names-only — Debian packages installed from
the base image's existing sources, with no allowlist because the surface is
already constrained — build-command is arbitrary shell, run as root, at
image-build time. It can fetch URLs, run installers, and write anywhere in the
image. That is the point (it is the escape hatch for a dependency apt cannot
express), and it is what makes it a larger grant.
The controls that keep it sound:
- Reviewed via the
vergil.tomlPR diff. The command is a plain string in the repo's ownvergil.toml; adding or changing it is a config change read and approved like any other in the PR diff. There is no separate allowlist (unlike the names-onlysystem-packagessurface, none is needed) — the diff is the review surface. - The fail-closed build is the backstop. The command runs while the cached
image is built; a non-zero exit fails the build rather than producing a
degraded image, exactly as the
system-packagesapt step does. A broken provisioning step stops the build, it does not leak into a mysteriously failing test. - Artifacts are image-resident and out-of-workspace. Whatever the command
installs lands in the image outside
/workspace(see the out-of-workspace contract above); it never writes into the mounted repo tree at run time. - No build-time network secrets by default. The build step runs with the
image build's environment, not the run-time forward set — a
build-commandsees a host secret only if the repo deliberately forwards it. Keep credentials out of the command string, which is stored invergil.tomlverbatim.
Inspecting the declared command¶
vrg-container-build-command reads the key through the same single accessor the
local bake and CI both use (container_build_command in
config.py):
vrg-container-build-command # the declared command (nothing if unset)
vrg-container-build-command --script # the same command, emitted for CI to run
Both modes print the command verbatim; a repo that declares no build-command
prints nothing. --script is the CI-consumption entry point — the CI test jobs
call it to obtain the exact command to run per job (see
CI Architecture → Repo-specific build steps),
mirroring how vrg-container-system-packages --install-script feeds the apt step.
Low-disk auto-prune (VRG_CACHE_PRUNE_FREE_GB, VRG_CACHE_BUILD_FREE_GB)¶
Not [container] keys: the image store belongs to the machine, not the repo, so
these are host environment variables.
Just before vrg-container-run (or vrg-container-cache build, or
vrg-finalize-pr's provisioning step) builds a cached dev image, it checks the
free space on the filesystem that holds the runtime's image store. When that is
below the threshold, it runs the same prune as
vrg-container-cache prune --apply against the same repo set, and prints each
image it removed.
| Variable | Default | Semantics |
|---|---|---|
VRG_CACHE_PRUNE_FREE_GB |
10 |
Free space, in GB, below which a cache build first prunes finished branches' images. 0 turns the auto-prune off; a value that is not a non-negative number stops the build |
VRG_CACHE_BUILD_FREE_GB |
3 |
Free space, in GB, a cache build needs after the prune to go ahead at all (one cached image is about 1.3–2 GB). Capped at VRG_CACHE_PRUNE_FREE_GB: when the threshold is lower, the threshold is the floor. 0 lets the build go ahead whatever is left; a value that is not a non-negative number stops the build |
- The repo set is never guessed. On the physical host, which has no default
repo set, the prune is skipped with a warning; run
vrg-container-cache prune --apply --root DIRby hand. - Failures are loud. A prune that cannot run, or an image it cannot remove, is printed as a warning.
- The build goes ahead only with room for it. After the prune, the build
continues if at least
VRG_CACHE_BUILD_FREE_GB(default 3 GB; or the threshold, if lower) is free, with a warning while still below the threshold. With less, it stops with the free-space numbers, the setting that set the floor, and the manual command, rather than fail part way through. - A bad value is never ignored. Both variables are read before every cache build, so a value that is not a non-negative number stops the build with an error naming the variable, even when the disk has room — never a silent fallback to the default.
- An unmeasurable store is skipped. Docker Desktop keeps its image store inside its own VM; there the check is skipped with a note.