Skip to content

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:

  1. resolves the path to an absolute one (a relative path is taken from the current directory);
  2. 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 -v bind syntax cannot carry). The error names the path;
  3. bind-mounts the file read-only at /run/vergil/version-window.json;
  4. sets VRG_VERSION_WINDOW=/run/vergil/version-window.json in 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.

VRG_VERSION_WINDOW=/abs/path/window.json vrg-container-run -- vrg-ci-resolve

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:

apt-get install -y --no-install-recommends <names>

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-run builds 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:

system package 'X' is not installable on linux/arm64 (apt: no installation candidate)

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 -g of a package with a bin, a pip install console script, a Go binary in /usr/local/bin) is on PATH and 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 -g of a library is not "on the default resolution path." A global npm install puts executables on PATH but does not add the npm global root (/usr/lib/node_modules) to Node's require search path. Without help, require("<lib>") / require.resolve("<lib>") returns MODULE_NOT_FOUND even 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_PATH is honoured by CommonJS require; ESM import ignores it. A library consumed via ESM import needs a different mechanism (e.g. a repo-local node_modules populated by npm ci, or staging the module into a directory ESM resolution walks up to).
  • Overridable. An explicit NODE_PATH in 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.toml PR diff. The command is a plain string in the repo's own vergil.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-only system-packages surface, 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-packages apt 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-command sees a host secret only if the repo deliberately forwards it. Keep credentials out of the command string, which is stored in vergil.toml verbatim.

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 DIR by 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.