Skip to content

Configuration reference

jailbee reads two configuration files:

  1. Per-repo: <repo>/.jailbee/config.yaml — required for a repo you work in regularly. A directory with no such file still works, though: unless scratch.enabled: false, jailbee synthesizes a repo layer from global.yaml's scratch block instead. See that section for what changes.
  2. Global: ~/.config/jailbee/global.yaml — optional, host-level only.

Run jailbee config init in a repo to generate a per-repo template.

Configuration layers

jailbee loads configuration from two YAML files and deep-merges them:

Layer Path Purpose
Global (user-level) ~/.config/jailbee/global.yaml (or $XDG_CONFIG_HOME/jailbee/global.yaml) Personal defaults applied to every repo (mounts, IDE preference, common egress endpoints).
Repo <repo>/.jailbee/config.yaml Per-repo configuration (stack versions, autostart, repo-specific egress, resources).

Repo-level values overlay user-level values. The effective Config Python object passed to every jailbee command is the merged result.

Merge rules

Source type Rule How to reset
Scalar (str, int, bool, Path, enum) Repo value replaces user value null in repo clears
List Repo list appended to user list (exception: apps.<name>.command replaces instead — see apps layering) [] in repo replaces with empty list
Map / dict Recursive deep-merge per key No bulk reset — set an individual key to null to clear it (an empty {} is a no-op)

Example: a user-level host_mounts entry plus a repo-level one yields two mounts after merge. A repo that needs to exclude a user mount must host_mounts: [] and re-list everything it wants.

Three keys are exempt from this pipeline — see Keys that bypass the deep-merge pipeline.

Field Layer Why
container_user.uid / container_user.gid global Constant per developer across repos
host_mounts (gitconfig) global Personal credentials
gpg.enabled global Personal — depends on whether host has gpg-agent. Default false.
ssh.* global Personal — depends on host SSH config. ssh.enabled default false.
jetbrains.enabled global Personal — turn on if you use a JetBrains IDE. Default false.
jetbrains.userprefs_from_host global Personal license state (off by default — opt in only to share host JBA login)
jetbrains.ai_enabled global Personal — turn on if you use JetBrains AI Assistant
jetbrains.toolbox_host_path global Personal Toolbox install path
browsers.chrome.enabled / browsers.firefox.enabled global Personal — turn on if you want jailbee chrome / jailbee firefox / auto-launch. Both default false.
browsers.chrome.dark_mode / browsers.firefox.dark_mode global Personal preference
browsers.chrome.host_path global Personal Chrome install path (default /opt/google/chrome). Firefox has no host default — it defaults to source: image instead.
browsers.default global Personal — which browser jailbee browser opens when more than one is enabled
apps.<name> repo A GUI app is part of the repo's tooling, like agents:
ls (column preference) global Which columns jailbee ls shows is personal; see ls:. dashboard: is deprecated — the dashboards keep their own view state instead, not a config block at either layer.
egress_allow (Claude API, JetBrains license hosts) global Cross-cutting, repo appends
optional_mounts (personal ~/.m2, ~/.aws) global Personal opt-in caches
defaults.{memory,cpu,...} repo Repo size determines limits
golden.{java,node,ubuntu_version} repo Stack-specific (golden.python is deprecated — see below)
golden.stacks repo Repo's runtime/tool set — see Stacks
golden.extra_apt_packages repo Repo's system-level deps
jetbrains.ide repo Repo's stack determines the IDE
jetbrains.autostart repo Repo decides whether autostart launches IDE
jetbrains.share_idea repo Repo decides whether to shadow VCS-tracked .idea/* with a per-repo shared mount
browsers.url (or per-browser browsers.chrome.url) repo Repo's app URL
browsers.chrome.autostart / browsers.firefox.autostart repo Repo's autostart workflow
autostart.on_create, autostart.on_start repo Repo-specific runtime workflow
container.env repo Repo-specific runtime env (NODE_OPTIONS, app feature flags, …)
container.path repo The repo's own scripts/, bin/; global for a personal ~/bin, repo appends
shared_dir repo only (auto-derived) Setting globally forces all repos to share the same dir

All fields are technically legal at either layer. The table above is convention.

Host-level docker_registry_mirror

The key docker_registry_mirror is ambiguous: GlobalConfig (host-level) uses it with the shape {port, enabled, image, data_dir}, while per-repo Config.docker_registry_mirror uses it with {extra_registries}. To disambiguate:

  • In ~/.config/jailbee/global.yaml, docker_registry_mirror is always interpreted as host-level (GlobalConfig). It is not merged into the Config layer.
  • In <repo>/.jailbee/config.yaml, docker_registry_mirror is interpreted as the per-repo Config.docker_registry_mirror override (with extra_registries).

If you need per-user defaults for extra_registries, set them per-repo. There is no global default for that field.

Keys that bypass the deep-merge pipeline

Six top-level keys are read from ~/.config/jailbee/global.yaml into GlobalConfig and are not merged into the Config layer: docker_registry_mirror (see above), ls, dashboard, claude_credentials, scratch and config_edit. ls's column block is merged field-by-field instead (repo block over global block) — the generic pipeline would append its fields/hide lists and concatenate the two layers' column lists rather than let one replace the other. dashboard is deprecated and is never merged this way — see ls:/dashboard:. claude_credentials is resolved to the single computed field Config.claude_credentials_dir instead of being merged at all — see claude_credentials below. scratch and config_edit describe this host rather than any one repo — what a directory with no config file gets, and how jailbee writes your files — so there is no repo-layer counterpart to merge them with; see scratch and config_edit.

One consequence: jailbee config show prints the Config layer, so the ls: / dashboard: values it shows come from the repo file only. Use jailbee config show --layer global to see what the global file contributes.

Inspecting the layers

  • jailbee config show --layer global — print the raw user-level YAML.
  • jailbee config show --layer repo — print the raw repo YAML.
  • jailbee config show (or --layer effective) — print the merged result.

Initialising both layers

  • jailbee config init — write <cwd>/.jailbee/config.yaml (the repo config).
  • jailbee config init --global — write ~/.config/jailbee/global.yaml (the user config).
  • --force overwrites an existing file in either case.

Provisioning snippets (install.d/)

The golden image is provisioned by src/jailbee/provision/install.sh, which performs LXC/Incus plumbing (user creation, sudoers, SSH_AUTH_SOCK passthrough, bind-mount parents, linger) and then runs every executable in /provision/install.d/*.sh in lexical order.

It also masks Ubuntu's automatic apt machinery in the image — apt-daily{,-upgrade}.timer, their services, and unattended-upgrades. A background upgrade in a branch container takes the dpkg lock out from under your own apt-get, and one still running at shutdown can block systemd long enough for a stop to time out. Containers get their updates from a rebuilt golden image (jailbee base build) instead; if you need the timers back in a particular repo, systemctl unmask them from an install.d/ snippet.

Resolution order

The bundled snippet set is split into two libraries, both shipped in the wheel:

  • install.d/ — always on. Stack-neutral plumbing only: locale, prompt, GUI libs, GitHub CLI, extra apt packages. No language runtime or cloud helper lives here.
  • install.d.available/ — opt-in. Language runtimes and cloud helpers; staged via golden.stacks (recommended — see Stacks below) or, at the low level, by naming the snippet directly in golden.enable_snippets (see Opt-in snippets below).
Source Path Precedence
Bundled (always-on) src/jailbee/provision/install.d/*.sh (shipped in the wheel) lowest
Bundled (opt-in, if enabled) src/jailbee/provision/install.d.available/*.sh (shipped in the wheel; staged via golden.stacks or golden.enable_snippets) above always-on, below user/repo
User ~/.config/jailbee/install.d/*.sh overrides bundled by filename
Repo <repo>/.jailbee/install.d/*.sh overrides user (and bundled) by filename

The effective set is computed at jailbee base build. New filenames from user or repo are simply added; same-filename files at a higher tier replace the lower-tier file. After resolution, names listed in golden.disable_snippets are dropped (suffix .sh is optional in the list — both "75-github-cli" and "75-github-cli.sh" work); disable_snippets wins over enable_snippets if the same name appears in both.

At runtime inside the container, install.sh skips any empty (zero-byte) snippet file. Combined with same-name shadowing, this gives a low-effort disable: drop an empty <repo>/.jailbee/install.d/<name>.sh and that snippet won't run in this repo's golden image.

Bundled snippets (install.d/ — always on)

Name Installs Reads env
05-extra-apt.sh Packages from golden.extra_apt_packages EXTRA_APT_PACKAGES
10-locale.sh en_US.UTF-8 locale
15-prompt.sh Bash prompt branch indicator ($JAILBEE_BRANCH) CONTAINER_USER
60-gui-libs.sh JetBrains/Chrome/Firefox runtime libs + fonts
75-github-cli.sh GitHub CLI (gh) from cli.github.com

Stacks (golden.stacks)

The recommended way to turn on a language runtime or cloud helper. Each key expands to the matching install.d.available/ snippet(s), the shared caches it needs, and the JAVA_PACKAGE/NODE_MAJOR build-env values — one field instead of an enable_snippets entry plus a manual shared_caches list.

Key Type Values Effect
java bool | string false (default) | true | "openjdk-N" | "corretto-N" true or "openjdk-N" stage 20-openjdk (apt default-jdk or openjdk-N-jdk); "corretto-N" stages 20-corretto (apt java-N-amazon-corretto-jdk). Either form adds the gradle/m2 shared caches.
node bool | int false (default) | true | N Stages 30-nodejs; NODE_MAJOR is N, or 24 when true. Adds the npm/pnpm-store shared caches.
python bool false (default) | true Stages 40-python.
docker bool false (default) | true Stages 50-docker.
ecr bool false (default) | true Stages 80-ecr-helper.

java and docker together also auto-stage 90-registry-mirror-ca (it imports the Docker registry mirror's CA into the JDK truststore). Opt out with golden.disable_snippets: ["90-registry-mirror-ca"] if this repo doesn't use the registry mirror.

Stack-derived snippet names are unioned with golden.enable_snippets (duplicates deduped); stack-derived shared caches are unioned with shared_caches the same way the claude/jetbrains auto-adds are — a manual entry with a matching name suppresses the auto-add.

Full stack, in one line:

golden:
  stacks: { java: corretto-17, node: 24, python: true, docker: true, ecr: true }

golden.enable_snippets/disable_snippets/shared_caches (and the version-pin fields golden.java/golden.node) remain available directly — they're the low-level escape hatch for anything stacks doesn't cover. See Opt-in snippets below.

Opt-in snippets (install.d.available/)

Low-level escape hatch. golden.stacks (above) is the recommended way to enable these; reach for enable_snippets directly only when stacks doesn't cover what you need. Bundled but off by default; stage a snippet by adding its logical name (or full filename) to golden.enable_snippets:

golden:
  enable_snippets: [nodejs, docker]
Name Logical name (for enable_snippets) Installs Reads env
20-openjdk.sh openjdk OpenJDK from the Ubuntu archive JAVA_PACKAGE
20-corretto.sh corretto Amazon Corretto JDK JAVA_PACKAGE
30-nodejs.sh nodejs Node.js + per-user ~/.npmrc NODE_MAJOR, JAILBEE_USER_HOME, CONTAINER_USER
40-python.sh python python${PYTHON_VERSION} + venv + pip PYTHON_VERSION (no auto-source — set via provision_env; golden.python is deprecated and does not feed it)
50-docker.sh docker Docker Engine + AppArmor systemd override; adds dev user to docker group CONTAINER_USER
70-chrome.sh chrome Google Chrome from Google's own apt repository
70-firefox.sh firefox Mozilla Firefox from Mozilla's own apt repository, pinned above Ubuntu's transitional/snap package
80-ecr-helper.sh ecr-helper amazon-ecr-credential-helper
90-registry-mirror-ca.sh registry-mirror-ca Imports /opt/jailbee-mirror-ca.crt into the Java truststore (no-op if absent)

registry-mirror-ca needs a JDK enabled too (it uses keytool — either openjdk or corretto); the Docker registry mirror needs docker enabled. golden.stacks auto-stages registry-mirror-ca whenever both java and docker are on (see Stacks above). Unknown names in enable_snippets are ignored with a warning at jailbee base build time.

70-chrome.sh and 70-firefox.sh are not staged via enable_snippets at all: golden.browser_snippet_names derives them straight from browsers.<name>.source — a browser enabled with source: image gets its snippet automatically, with no separate toggle to keep in sync. Both run after the always-on 60-gui-libs.sh, which installs the shared X11/Wayland/EGL libraries and fonts a browser needs to render at all. 70-chrome.sh fails the build outright on an arm64 golden-image host — Google publishes no linux/arm64 package — and points at browsers.chrome.source: host or Firefox instead.

Snippet contract

Every snippet runs as root inside the golden-build container. The header convention is:

#!/bin/bash
# <name> — <short description>
# Env: <env vars consumed>
# Installs: <what gets added to the image>
set -euo pipefail
# ...

Guaranteed environment variables (set by jailbee base build regardless of which snippet you author):

Variable Source Notes
CONTAINER_USER hardcoded dev The unix username inside the container.
CONTAINER_UID, CONTAINER_GID container_user.{uid,gid} Match host uid/gid for bind-mount readability.
JAVA_PACKAGE golden.stacks.java (preferred) or golden.java (mapped) apt package name (e.g. java-17-amazon-corretto-jdk).
NODE_MAJOR golden.stacks.node (preferred) or golden.node Major version for NodeSource (e.g. 24).
EXTRA_APT_PACKAGES golden.extra_apt_packages Whitespace-separated; may be empty.
JAILBEE_USER_HOME /home/dev (constant) Lets snippets avoid hardcoding the path.
JAILBEE_PROVISION_DIR /provision (constant) Where snippets are staged inside the container.

Custom env vars can be passed via golden.provision_env. All of the above except CONTAINER_USER are reserved — passing one via provision_env raises ConfigError. CONTAINER_USER is always dev.

Disabling bundled snippets

Two ways:

  1. Config: add to golden.disable_snippets (recommended for documentation reasons).
golden:
  disable_snippets:
    - "60-gui-libs"
    - "80-ecr-helper"
  1. Empty shadow: drop an empty file at the same path under <repo>/.jailbee/install.d/ (or ~/.config/jailbee/install.d/).
mkdir -p .jailbee/install.d
: > .jailbee/install.d/60-gui-libs.sh

The config approach makes the choice visible in jailbee config show; the shadow approach is useful when you want to inspect the disable state via the filesystem.

Note: 90-registry-mirror-ca.sh depends on keytool from the JDK installed by 20-corretto.sh/20-openjdk.sh. If you disable whichever JDK snippet your golden.stacks.java/golden.enable_snippets staged, also disable 90-registry-mirror-ca.sh (or keytool won't be on PATH and the snippet will fail).

Full provisioning override

golden.provision_script (path, relative to repo root) replaces the bundled install.sh entirely. When set, install.d/ snippets are not staged — the custom script owns the whole provisioning surface. This escape hatch exists for repos that need to do something fundamentally different (e.g. a non-Ubuntu base image).

A replacement script inherits nothing from install.sh, including the apparmor package. On a host that sets kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 and later), the dev user can only create a user namespace if AppArmor policy is loaded inside the container. Without that policy, Chrome aborts at launch with credentials.cc … Permission denied, and bwrap and rootless podman fail too. A custom script should install apparmor, as install.sh does. Its comment explains why.

Per-repo config (.jailbee/config.yaml)

All keys are optional. An empty file ({}) is valid and yields full defaults. The schema is fail-closed — unknown keys are rejected.

container_user

UID/GID of the user account inside the container. The unix username is hardcoded to dev. It used to be configurable, but nothing enforced consistency between that value and the user baked into the golden image, and a mismatch surfaced as a confusing Permission-denied error.

Key Type Default Description
uid int current host uid UID inside container
gid int current host gid GID inside container

container

Container-wide settings applied via the Incus base profile.

Key Type Default Description
env map {} Env vars injected into every process Incus starts in the container — jailbee shell, jailbee tmux, autostart steps, and any nested tmux/shell. Values are passed through verbatim (no shell expansion). Keys must match [A-Za-z_][A-Za-z0-9_]*.
path list[string] [] Container-side directories prepended to PATH — repo-internal scripts, a vendored toolchain. See container.path below.

container.env is ambient: it applies to interactive shells (jailbee shell), the autostart tmux session (jailbee tmux), and every autostart step. Per-step overrides live under autostart.env (every step) or autostart.on_{create,start}[*].env (one step), and win on conflict because they are passed as tmux new-window -e.

User entries also win over JailBee's own GUI/SSH defaults (DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR, SSH_AUTH_SOCK) — set them only if you know why you want to.

WAYLAND_DISPLAY is the exception to "profile changes need jailbee apply": unless you pin it here, JailBee re-points it at the compositor socket it bind-mounts on every container start, so a host whose socket is renumbered (wayland-1) needs no re-apply. That is why incus config show <container> lists the key on the instance and not only on the profile.

Profile changes take effect after jailbee apply (which prompts to restart running containers).

Example:

container:
  env:
    NODE_OPTIONS: "--max-old-space-size=4096"
    MYAPP_FEATURE_FLAG: "1"

container.path

Directories prepended to PATH inside the container. The common case is a repo's own scripts/ or bin/ directory: the checkout is already there, and this is what makes deploy-staging work as a bare command instead of ./scripts/deploy-staging.

container:
  path:
    - scripts           # -> /home/dev/<container_prefix>/scripts
    - tools/bin
    - ~/bin             # -> /home/dev/bin
    - /opt/vendor/bin   # used as given

Resolution is container-side — the host never looks these paths up, so nothing here is checked for existence and a typo is silent:

  • a relative entry resolves against the container's repo checkout (/home/dev/<container_prefix>),
  • a leading ~ against the container user's home (/home/dev),
  • an absolute entry is used as given.

Entries are prepended in the order written, so the first one wins a name collision. An entry may not be blank or contain a : (the PATH separator itself) or a newline — those are rejected at load time.

Because container.path lands in the Incus base profile, it reaches every way into the container: jailbee shell, jailbee exec, tmux windows, autostart steps, and GUI launches like jailbee ide / jailbee apps run (which do not go through a login shell). jailbee apply is enough to pick it up — no image rebuild, and no container restart.

Two consequences of that mechanism, both deliberate:

  • The profile replaces PATH rather than extending it. Incus offers no way to reference the previous value, so once a repo sets container.path, JailBee spells out the system half itself: /usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin. A container image whose own default carries something extra (/snap/bin) loses it. Leave container.path empty and no PATH is written at all, which is why a repo that has not opted in is unaffected.
  • ~/.local/bin is not in that system half. It reaches PATH through /etc/profile.d/local-bin.sh, which every login shell sources on top of this value — so login shells keep it, and a non-login incus exec resolves exactly what it resolved before.

container.env.PATH overrides container.path outright. Setting both is not an error, but the container.path entries are then ignored and jailbee config validate says so.

In the layered config, container.path follows the list-append convention: global.yaml's entries come first, the repo's are appended after them — so a global entry wins a collision against a repo one.

shared_dir

Host directory that holds JailBee's shared state: shared caches (pnpm, gradle, npm, m2), JetBrains config/data, Chrome and Firefox pool slots, and Claude state. It is the host-side root for these — each entry is bind-mounted individually at its own in-container path (e.g. ~/.claude, and the cache paths), not under a single /mnt/shared mount point.

| Default | ~/.local/share/jailbee/shared/<container_prefix> |

A .owner stamp file is written here on first jailbee init. Two repos with conflicting shared_dir paths will fail loudly on the second jailbee init.

share_local

Key Type Default Description
share_local bool true When true and a directory <repo_root>/.local exists, RW-bind-mount it into each new container at ~/<container_prefix>/.local as a host<->container file-transfer channel. Presence-triggered: an absent .local dir is a silent skip and is never auto-created. Skipped in --mount mode (the full-repo RW bind already exposes it). Set false to disable entirely.

host_mounts

List of bind mounts added to every container.

Field Type Default Description
host path Host path (~ expanded).
container string In-container mount target.
readonly bool false Read-write unless set to true — set true for anything sensitive.

optional_mounts

Named mounts, attached per-container with jailbee new --mount NAME.

Field Type Default Description
host path Host path (~ expanded).
container string In-container mount target.
readonly bool true Read-only by default.
description string "" Shown in the jailbee new --mount picker.

host_devices

Pass arbitrary host character/block devices into every container as Incus unix-char / unix-block devices. Opt-in, default empty.

host_devices:
  - { path: /dev/kvm }                        # Android emulator / KVM VMs
  # - { path: /dev/net/tun }
  # - { source: /dev/bus/usb/001/004, path: /dev/bus/usb/001/004 }
Field Meaning Default
path device path inside the container; absolute required
source host device path; absolute defaults to path
type unix-char or unix-block unix-char
mode node mode on the Incus profile device (octal string) "0666"
gid / uid node owner on the Incus profile device unset
group container group the dev user is added to for access auto

Layered like host_mounts: per-repo entries append to global ones; [] resets. A device whose host source is absent is skipped (JailBee does not fail), and jailbee config validate reports it as an advisory — so a team-shared config still works on hosts that lack the device.

How the dev user gets access (group, not mode). The reliable access mechanism is group membership, not the profile mode. Many host devices (/dev/kvm, /dev/net/tun, /dev/fuse, …) carry a udev static_node rule, so the container's own systemd-udevd resets the node to its distro default (e.g. /dev/kvmroot:kvm 0660) on every boot — overriding whatever mode the Incus profile set. (Verified: neither the profile mode nor an in-container udev override survives this; only group membership does.) So JailBee adds the dev user to the device's owning group:

  • When group is unset (default), JailBee auto-derives it from the host source node's owning group — /dev/kvmkvm. Zero-config: { path: /dev/kvm } just works.
  • Set group explicitly to override (e.g. when the host and container group names differ): { path: /dev/kvm, group: kvm }.
  • mode/gid/uid still apply to the Incus profile device and remain useful for devices without a static_node rule (e.g. the GPU render nodes, which keep 0666).

Group membership takes effect on the next session. JailBee runs the group-add on jailbee new and on jailbee apply (for running containers). A new jailbee shell / jailbee tmux / autostart session picks the group up via incus exec --user's supplementary-group setup; an already-open shell must be reopened. Check with id inside the container — the device's group (e.g. kvm) should appear.

Security. JailBee containers are unprivileged (userns + raw.idmap), so /dev/kvm does not grant container escape, host root, or new filesystem access on its own. It does widen the host-kernel attack surface: KVM ioctls run in host-kernel context, so a process that can open /dev/kvm could in principle exploit a KVM kernel bug to escalate past the userns isolation. For a single-user dev box where the host user already runs VMs this is the same trust boundary they already extend to their own account. Treat every host_devices entry as attack-surface-widening and list only what the repo's workflow needs.

host_ports

Make a host service reachable inside every container of the repo — the classic case is an adb server: with the forward in place, plain adb devices works inside the container, and no ADB_SERVER_SOCKET juggling is needed, because the host's adb server already listens on 127.0.0.1:5037 by default.

host_ports:
  - { name: adb, port: 5037 }

Each entry becomes one Incus proxy device (named port-cfg-<name>): the container listens on container_address:port, and Incus's forkproxy connects to host_address:host_port on the host whenever something inside the container connects to that listener. So port/container_address name the container-side listener, and host_port/host_address name the host service it reaches.

Field Type Default Description
name string required Handle for this forward. Must match [a-z0-9][a-z0-9-]*, max 40 chars, unique within host_ports. Becomes the Incus device name port-cfg-<name> and the jailbee port rm key.
port int required Container-side port (1–65535) — what listens inside the container.
host_port int port Host-side port the container connects to. Set this when the container-side port and the host-side port differ.
proto tcp | udp tcp Protocol.
host_address string 127.0.0.1 Host address the container connects to. Must be an IP literal — a hostname is rejected, because resolving one at device-add time would silently pin a single IP into the device.
container_address string 127.0.0.1 Container address the proxy listens on. Must also be an IP literal.

A worked example with the two ports differing — forwarding a host service on port 9000 to port 3000 inside the container:

host_ports:
  - { name: api, port: 3000, host_port: 9000 }

Here the container listens on 127.0.0.1:3000; anything the container connects to at 3000 actually lands on the host's 127.0.0.1:9000.

Only this direction is configurable. A host-side listener is a machine-wide resource: if a repo's config declared one, every container of that repo would fight over the same host port, breaking the property that many branch containers of the same repo coexist. The reverse direction — a container service reachable on the host — is not something host_ports exposes at all; use jailbee port to-host per container instead (see Commands). A direction:/to_host:/bind: key in a host_ports entry is rejected with this same explanation, not a generic "unknown field" error.

This is a hole through the net strict ACL's egress half by construction. The forwarded traffic never traverses the bridge the ACL is attached to — Incus's forkproxy connects directly out of the container's network namespace to the host — so a strict container's default-deny ACL never sees it, on the egress side. (jailbee port to-host's forwards are the ingress-side mirror of this same hole; host_ports only ever opens the egress one.) See Security and limitations for the full picture.

Entries are attached when jailbee new creates a container, and reconciled by jailbee apply: an entry that's new is added, one whose properties changed is replaced, and one that's been deleted from the config is removed. There's no rebuild and no restart — proxy devices hotplug on a running container. Reconciliation only ever touches port-cfg-* devices; a forward you added by hand with jailbee port is never modified or removed by it.

Layered like host_mounts/host_devices: per-repo entries append to global ones; [] resets.

shared_caches

The state layer every container of this repo has in common. Each entry is bind-mounted read-write into all of them, which is what makes a tool worth configuring once: a package-manager cache stays warm across branches instead of being refilled per container, and settings written in one container are visible in the next. The same mounts outlive jailbee destroy / jailbee new, and they live in <shared_dir> rather than in your host's dotfiles, so a container can write to them freely without touching your own setup.

That "one cache, all containers" description is the plain shared-mount case. An entry whose pool resolves to non-None (see pooled_caches below) is not a shared mount at all: it is a per-container pool slot, seeded from the warmest existing slot rather than shared live. Gradle and Maven default to pooled, precisely because their tools take an inter-process lock on the cache directory — sharing one mount across containers meant one build's lock made every other container's build wait or fail.

List of bind-mounted shared caches, each {name, host_subpath, container_path}. The host source is <shared_dir>/<host_subpath>; container_path may start with ~ (expands to /home/dev).

Default is stack-neutralssh only:

name host_subpath container_path
ssh ssh ~/.ssh

The language caches (pnpm-store, gradle, npm, m2) that used to ship as defaults are now opt-in. Enabling the matching golden.stacks key (recommended) adds them automatically via Config.effective_shared_caches(); the low-level alternative is listing them by hand under shared_caches: alongside the matching golden.enable_snippets entry.

The JetBrains entries (jetbrains-config~/.config/JetBrains, jetbrains-data~/.local/share/JetBrains) are not defaults; they are appended by Config.effective_shared_caches() when jetbrains.enabled: true. See ### jetbrains below.

The claude entries follow the same pattern. Two claude rows are appended by Config.effective_shared_caches() when claude.enabled: true: claude~/.claude and claude-install~/.local/share/claude. Claude Code's global config (.claude.json) lives inside the shared ~/.claude mount: the golden image exports CLAUDE_CONFIG_DIR=$HOME/.claude, and Claude Code reads (CLAUDE_CONFIG_DIR || $HOME)/.claude.json. See ### claude below.

chrome-profile (chrome-pool~/.config/google-chrome) and firefox-profile (firefox-pool~/.mozilla/firefox) are appended the same way, when browsers.chrome.enabled: true / browsers.firefox.enabled: true respectively. See ### browsers below.

ssh is seeded on first jailbee init from host ~/.ssh/ (config, known_hosts, config.d/) when ssh.seed_from_host is on (default). The container then has RW access to its own ~/.ssh/ — ControlMaster sockets work, known_hosts updates persist, and users can drop their own keys into <shared_dir>/ssh/ if they don't want to use the host gpg-agent. Private keys, authorized_keys, and sockets are never seeded — they live outside the strict allowlist. The dir is forced to mode 0700 on every jailbee init (SSH refuses ~/.ssh with looser bits). Disable seeding with ssh.seed_from_host: false, or skip the whole SSH integration with ssh.enabled: false.

Set shared_caches: [] to disable, or override with your own list for non-JVM/Node stacks. name must match [a-z0-9][a-z0-9-]* and be unique. container_path must be absolute or start with ~.

An entry can carry its own pool: block instead of relying on pooled_caches below — see SharedCache.pool in the next section.

pooled_caches

Key Type Default Description
pooled_caches dict of name → bool {} Per-cache override of pooling. true pools a shared_caches entry using its builtin preset (POOL_PRESETS[name]); false keeps it a plain shared mount. A key naming a cache with no builtin preset is rejected at load time unless that cache's shared_caches entry carries its own pool: block. chrome-profile: false and firefox-profile: false are also rejected: each one's host directory is its pool root, so an un-pooled mount would point every container at the pool's own slots/ and by-container/. Use browsers.chrome.enabled: false / browsers.firefox.enabled: false to turn that browser off instead.

A pooled cache is not mounted by the binds profile like the rest of shared_caches. Instead each container gets its own slot directory under <shared_dir>/<host_subpath>/slots/, attached as a per-container disk device named <cache name>-slot — allocated on jailbee new and on every boot (or on first use, for chrome-profile and firefox-profile), released on jailbee destroy. This is what stops two containers from contending on one tool's lock files: Gradle and Maven both take an inter-process lock on their cache directory, so a build in one container used to block or fail while another container's build held it.

A key absent from pooled_caches follows the preset's own default_on:

Preset default_on What's hardlinked (link_paths)
gradle true caches/modules-2/files-2.1, wrapper/dists
m2 true repository
chrome-profile true none — SQLite + Preferences are rewritten in place
firefox-profile true none — places.sqlite and prefs.js are rewritten in place
npm false _cacache
pnpm-store false v3/files

pooled_caches is a dict rather than a list specifically so ~/.config/jailbee/global.yaml and a repo's .jailbee/config.yaml merge per key (the generic dict rule from Merge rules) instead of one layer's list appending to the other's.

A fresh slot is seeded by copying the warmest existing slot (the one whose warmth_file — or, absent that, whose directory — has the newest mtime). link_paths names subtrees hardlinked from the seed source instead of copied, so a multi-gigabyte artifact store (Gradle's module cache, Maven's repository/) costs close to nothing per extra container. link_paths may only name subtrees whose files are written once and later deleted whole, never modified in place — hardlinking a lock file, or a .bin that a tool rewrites in place, would restore exactly the cross-container sharing pooling exists to remove. wipe_paths and stale_globs are the other side of that same rule: content excluded from seeding and removed when a slot is released — regenerable bulk (Gradle's daemon/ dir) and stale lock files an unclean exit left behind, respectively. Both accept glob patterns; wipe_paths needs them for Firefox, whose profile directory carries a random <id>.default-release/ component that no literal path can name (*/cache2).

To pool a cache with no builtin preset — including one of your own shared_caches entries — give that entry an explicit pool: block instead of a pooled_caches key:

shared_caches:
  - name: my-tool-cache
    host_subpath: my-tool
    container_path: ~/.cache/my-tool
    pool:
      link_paths: [blobs]
      stale_globs: ["*.lock"]

An explicit pool: block on a shared_caches entry always overrides pooled_caches — even a pooled_caches: {my-tool-cache: false} key does not un-pool it. This is also true of the presets themselves: setting pool: on the gradle/m2/chrome-profile/firefox-profile/npm/ pnpm-store entries replaces their builtin PoolSpec outright rather than merging into it.

jailbee pool ls [NAME] / jailbee pool prune [NAME] inspect and clean pool slots — see commands.md. A pre-existing non-pooled cache is migrated automatically: jailbee init and jailbee apply move a cache sitting directly under the pool root into slots/slot-0, so the warm cache becomes the first seed source rather than being discarded. A pooled cache attaches to a container when that container next boots, so restart any container that was running during jailbee apply (jailbee restart <name>) before trusting it to be using its own slot; jailbee doctor flags a pool root that still needs migrating.

Networks

jailbee ships two hardcoded network modes — strict, loose — selectable per container via defaults.network or per autostart step via network. The modes are not user-configurable: their names, semantics, and the descriptions stamped onto the generated Incus profiles all live in code.

Mode Egress behaviour
strict Default-deny ACL; only egress_allow destinations reachable.
loose All egress permitted (dedicated jailbee-loose bridge).

egress_allow

List of allowed egress destinations for strict mode. loose ignores this list. Each entry takes one of six forms:

Form Meaning Example
<hostname> Resolve via DNS, allow any protocol and port to each IPv4 github.com
<hostname>:<port> Resolve, allow only TCP/<port> github.com:22
<ipv4> Allow any protocol and port 192.168.1.5
<ipv4>:<port> Allow only TCP/<port> 192.168.1.5:5432
<cidr> Allow any protocol and port 10.0.0.0/8
<cidr>:<port> Allow only TCP/<port> 10.0.0.0/8:5432

The port-less forms emit an ACL rule with no protocol field at all, which Incus reads as "any protocol" — UDP and ICMP to that destination included, not only TCP. Adding :<port> narrows the rule to TCP. Prefer the host:port form when you know the port: github.com:443 is a materially tighter rule than github.com.

Hostname entries are resolved to IPv4 addresses at ACL-apply time (during jailbee init and jailbee apply), and all A records returned by the resolver are inserted — useful for CDN-fronted services that round-robin a small pool. Resolution does not stop there: the jailbee net refresh timer re-resolves every registered repo's hostnames each minute into a cumulative IP pool (SQLite-backed, 24 h TTL per IP, capped per host), so a service that rotates through a set of addresses accumulates all of them and stale ones expire on their own. The same pass rewrites the ACL and mirrors the allowed IPs into each strict container's /etc/hosts, so the container's own resolver answers with exactly the addresses the ACL permits instead of drifting to an IP the ACL will drop. jailbee apply --no-restart forces the same refresh immediately, live, without restarting a container.

Error handling: if any hostname fails to resolve, the entire ACL apply is aborted with a non-zero exit code — the previous ACL remains in place. The list is meant to be minimal; a broken entry is treated as a real config error, not a soft warning.

Limitations: IPv6 is not supported (a single : is the host/port separator, which would clash with IPv6 syntax). The host:port form is TCP-only — there is no way to allow a specific UDP port, so UDP to a destination is all-or-nothing via the port-less form (DNS and DHCP are allowed unconditionally, independent of this list).

github.com and strict-mode push: github.com is intentionally not in the default egress_allow, so strict-mode work runs offline-of-GitHub. The operational workflow — why this is the gate against unattended agents producing surprise pushes, and how to switch to loose-mode to push/fetch/run gh — is documented in Security and limitations.

Widening the list without editing it. This key is the committed, shared-by-everyone allowlist. jailbee net egress add <entry> [<name>] widens one container's copy of it, and --repo this host's copy of the repo's, without touching config.yaml — useful for a host that only one developer needs, or for trying an entry before proposing it to the team. Container-scoped entries live in the container's own user.jailbee.egress_extra label, die with the container and are materialised as the command runs; repo-scoped ones are host-local state, not in git, and go live on the next jailbee apply. Overrides are additive only: they can never narrow what this key grants, so reading egress_allow still tells you the minimum every container of the repo can reach — but not the maximum on a given machine, which is what jailbee net egress ls and jailbee net status report. jailbee net egress export prints the whole key back with the promotable overrides folded in, for when a temporary entry has earned its place here. See Egress overrides for the security posture and Commands for the flags.

loose_auto_revert

Auto-reverts jailbee net loose <c> back to the previous network mode after a TTL. Lives in both ~/.config/jailbee/global.yaml and per-repo .jailbee/config.yaml; per-repo overrides global field by field, so a repo can change just after and inherit enabled from the global file.

loose_auto_revert:
  enabled: true   # default true
  after: 5m       # default 5m — accepts `30s`, `5m`, `2h`, or raw int (minutes)

When jailbee net loose <c> schedules a TTL, two container labels are written: user.jailbee.loose_until (ISO8601 expiry) and user.jailbee.loose_revert_to (the mode in effect before the switch). The existing jailbee-net-refresh.timer (already runs every 60 s) reverts the container when the deadline passes — unless user.jailbee.autostart_in_progress is set, in which case the check is deferred to the next tick so an autostart stage can finish its own network swap without racing the timer. See Security for what carries that flag for a detached stage, and why a dead supervisor can no longer pin a container loose.

jailbee net strict <c> always clears the labels.

Choosing the TTL per switch

The after value above is the default. Each jailbee net loose decides its own TTL:

jailbee net loose mybug --for 2h      # this switch reverts after 2h
jailbee net loose mybug --for 45m     # any `<int>s|m|h`, up to 24h
jailbee net loose mybug --for never   # no auto-revert (same as --no-revert)
jailbee net loose mybug --no-revert   # stay in loose until manually switched

--for and --no-revert are mutually exclusive. A value over 24h, or one JailBee cannot parse, exits 2 without switching.

With neither flag, JailBee asks — but only when all of these hold: stdin is a TTY, JAILBEE_NONINTERACTIVE is unset, and the effective policy is enabled. The prompt offers a preset list with after pre-selected and labelled (config default), plus no auto-revert, custom… (type any accepted duration) and cancel (aborts without switching). Anywhere else — a script, a CI job, JAILBEE_NONINTERACTIVE=1, or the Qt dashboard, which launches actions detached with no stdin — no question is asked and after applies. To stay non-interactive and explicit, pass --for or --no-revert.

The Qt dashboard asks in a dialog instead, pre-selecting the same configured default and validating a typed value with the same parser as the CLI.

With enabled: false

A disabled policy means JailBee schedules no TTL of its own: jailbee net loose writes no labels, and neither the CLI prompt nor the GUI dialog appears. It does not veto an explicit request — --for 2h still writes the labels and the timer still reverts the container when they expire. A stated intent wins over the config switch, so what jailbee ls and jailbee net status display always matches what will happen.

jailbee ls shows the remaining TTL in a dedicated column (visible only when at least one container is in loose mode), and jailbee net status lists each loose container with its expiry time.

defaults

Per-container defaults.

Key Type Default Description
memory string 16GiB Memory limit for new containers.
cpu int 8 CPU limit for new containers.
network enum strict Initial network mode: strict | loose.
storage_pool string default Incus storage pool for new containers.

golden

Golden image build params.

The golden image is stack-neutral by default: only locale, prompt, GUI libs, and GitHub CLI are installed out of the box (see Provisioning snippets below). Language runtimes and cloud helpers (Java, Node, Python, Docker, ECR helper, registry-mirror CA) ship in the image but stay off until enabled — via golden.stacks (recommended, see Stacks) or directly via golden.enable_snippets (low-level).

Key Type Default Description
alias string <container_prefix>-base Image alias used by jailbee base build.
ubuntu_version string 26.04 Ubuntu image tag pulled from images:.
java string amazon-corretto-17 Java identifier. amazon-corretto-N maps to apt package java-N-amazon-corretto-jdk; everything else is passed through as an apt package name. Only takes effect when the matching snippet is staged (golden.stacks.java or golden.enable_snippets).
node int 24 Node.js major version (used by NodeSource). Only takes effect when the nodejs snippet is staged (golden.stacks.node or golden.enable_snippets).
python string "" Deprecated and ignored. The container's Python is always the base image's system python3 (its version is a function of ubuntu_version — the Ubuntu archive ships one python3.X per release). Setting this raises a soft warning in jailbee config validate and jailbee base build; the value has no effect. Need a different Python? Add it via extra_apt_packages (e.g. python3.12, if the base archive has it).
provision_script path null (= bundled install.sh) Path to an alternative provisioning script. Relative paths resolve against the repo root.
provision_env map {} Extra env vars passed to the provisioning script. Reserved keys — CONTAINER_UID, CONTAINER_GID, JAVA_PACKAGE, NODE_MAJOR, EXTRA_APT_PACKAGES, JAILBEE_USER_HOME, JAILBEE_PROVISION_DIR — raise ConfigError (the bundled install.sh relies on them).
extra_apt_packages list[string] [] Extra apt package names installed by the bundled 05-extra-apt.sh snippet (via EXTRA_APT_PACKAGES). Each entry must match [a-z0-9][a-z0-9+\-.]*.
disable_snippets list[string] [] Names of bundled snippets to drop from the effective set at jailbee base build. Matches the logical name ("registry-mirror-ca"), the numbered name ("90-registry-mirror-ca"), or the full filename ("90-registry-mirror-ca.sh") — same name forms enable_snippets accepts. Also drops snippets auto-added by golden.stacks. See Disabling bundled snippets.
enable_snippets list[string] [] Names of opt-in install.d.available/ snippets to stage into the effective set (by logical name, e.g. "nodejs", or full filename, e.g. "30-nodejs"/"30-nodejs.sh"). Unioned with the snippets golden.stacks implies. See Opt-in snippets. Unknown names are ignored with a warning.
stacks object all fields off High-level java/node/python/docker/ecr toggles — the recommended way to enable a runtime. See Stacks.

Master switches — opt-in by default

Every host-tooling block (gpg, ssh, jetbrains, and each browser under browsers) ships with enabled: false. None of them does anything until the user opts in, typically at the global layer (~/.config/jailbee/global.yaml). Per-repo overrides can also turn a block on for repos that need it. The point is to keep the container minimal until the user explicitly says "yes, wire this host integration in."

jailbee config init --global writes a file that flips gpg, ssh, jetbrains and browsers.chrome to enabled: true — a working starting point for a typical developer setup, not a literal echo of the built-in defaults. browsers.firefox is left off in that seed; enable it by hand if you want it too. The file itself is generated, not hand-written: its comments are each field's own schema description (config_writer.render_documented), so they cannot drift from this document or the code the way a hand-maintained template could.

gpg

Key Type Default Description
enabled bool false RO bind-mount ~/.gnupg, attach the host /run/user/<uid>/gnupg socket dir as the gpg-socket device (read-only), and set SSH_AUTH_SOCK in the base profile to the host gpg-agent's SSH socket. Disables the doctor gpg-agent socket check when false.

When enabled: true, the host gpg-agent provides SSH authentication inside the container (YubiKey / GPG-SSH keys work transparently). The auto-added bind-mount can be overridden by adding a manual entry to host_mounts with container: /home/dev/.gnupg — the manual entry wins.

The gpg-socket device is mounted read-only, and so is pulse-socket. Both are directories inside the host's own /run/user/<uid>, and the container runs its own systemd --user: its gpg-agent.socket, dirmngr.socket and pulseaudio.socket listen on paths inside those mounts and unlink whatever file is already there before binding — which would take the host's agent down (socket file has been removed - shutting down) on every container boot. Read-only makes that unlink EROFS while leaving the socket fully usable, since a unix-socket client needs no writable filesystem, and the host stays free to re-create its own sockets. The golden image also masks those user units, so the container does not even try.

With enabled: false nothing gpg-related reaches the container: no ~/.gnupg mount, no gpg-socket device, and no SSH_AUTH_SOCK. The golden image's /etc/profile.d/jailbee-env.sh fallback only sets SSH_AUTH_SOCK when the variable is still unset and the gpg-agent socket is actually present, so login shells stay clean on hosts that run no gpg-agent.

ssh

Key Type Default Description
enabled bool false Bind-mount <shared_dir>/ssh as the container user's ~/.ssh and enforce 0700 on each jailbee init. false skips the mount and the perms check; the container will have no ~/.ssh.
seed_from_host bool true On first jailbee init, copy host ~/.ssh/{config, known_hosts, config.d/} into the shared dir. Private keys, authorized_keys, and sockets are never seeded. Ignored if enabled: false.

jetbrains

Key Type Default Description
enabled bool false Master switch. When false, jailbee ide exits 2 with a clear message, autostart skips the IDE launch, the userprefs / toolbox auto-mounts are omitted from effective_host_mounts, and the JetBrains egress hosts are NOT auto-added to egress_allow. The individual sub-toggles below have no effect.
ide enum idea Which JetBrains binary jailbee ide (no --app) and autostart launch. Supported: idea \| webstorm \| pycharm \| goland \| clion \| phpstorm \| rider \| rubymine \| datagrip \| rustrover \| aqua \| dataspell \| studio.
userprefs_from_host bool false Opt-in RW bind-mount of ~/.java/.userPrefs/jetbrains/ (host JetBrains Account / license tokens). Enable only if you want to reuse the host's JBA login state across containers. Does not affect egress (license-host egress is gated only on enabled). Ignored when enabled: false.
share_idea bool true Bind-mount <shared_dir>/jetbrains-idea over ~/<container_prefix>/.idea inside each container so project-side JetBrains state (run configs, code styles, inspection profiles, project view) survives jailbee destroy / jailbee new cycles. The mount is attached as a per-container device after git clone (it cannot live in the binds profile — Incus pre-creating the target would break the clone). Skipped automatically in --mount mode so the host's own .idea/ wins. Set to false if the source repo tracks .idea/* files in VCS that should remain visible to the IDE — the shared dir starts empty and would otherwise shadow them on first launch. Ignored when enabled: false.
ai_enabled bool false Opt-in: also auto-extend strict-mode egress_allow with the JetBrains AI Assistant backend hosts (api.app.prod.grazie.aws.intellij.net, api.jetbrains.ai). Leave off unless you actually use AI Assistant. Ignored when enabled: false.
autostart bool false Launch the IDE after autostart steps. No-op if no graphical session is detected. Ignored when enabled: false.
toolbox_host_path path | null ~/.local/share/JetBrains/Toolbox Host path RO-mounted to /opt/jetbrains-toolbox. The container-side path is hardcoded because jailbee's IDE launcher looks for binaries there. null disables the auto-mount. Ignored when enabled: false.

When jetbrains.enabled is true, egress_allow is automatically extended (in strict-mode ACL generation only — the YAML field is not modified) with the JetBrains hosts the IDE needs for account/license activation, plugin marketplace and installer CDNs: account.jetbrains.com, oauth.account.jetbrains.com, cloudconfig.jetbrains.com, plugins.jetbrains.com, downloads.marketplace.jetbrains.com, www.jetbrains.com, resources.jetbrains.com, download.jetbrains.com, download-cf.jetbrains.com, download-cdn.jetbrains.com, frameworks.jetbrains.com, data.services.jetbrains.com, api.jetbrains.cloud (all port 443). The list is sourced from JetBrains' published allowlist guidance plus empirical observation of the JBA sign-in flow. Without these, the IDE falls back to "Start free trial" after the locally cached license expires (~30 days for paid plans), and plugin updates / framework dependency lookups fail in strict mode. resources.jetbrains.com serves the OAuth provider icons rendered in the JBA sign-in dialog — blocking it prevents the login UI from finishing, so the IDE silently stays in trial state. oauth.account.jetbrains.com is the OAuth sign-in endpoint (AWS ELB in eu-west-1, distinct IP space from account.jetbrains.com); without it the sign-in handshake cannot complete. downloads.marketplace.jetbrains.com is the CloudFront-backed plugin payload CDN, separate from plugins.jetbrains.com. api.jetbrains.cloud (note the .cloud TLD) hosts the license trace-status endpoint.

When jetbrains.ai_enabled is also true, the AI Assistant backend hosts are appended too: api.app.prod.grazie.aws.intellij.net, api.jetbrains.ai (both port 443).

When jetbrains.userprefs_from_host is true, concurrent host+container IDE writes to the same files are possible but rare in practice (login tokens are written once per session). If both write at the same time, last-flush wins; the loser's in-memory state diverges until the next IDE restart.

browsers

Chrome and Firefox as registry entries — see apps below for the umbrella both feed into, and Provisioning snippets above for how source: image reaches the golden image.

Turning on a browser for the first time needs jailbee base build too, not only a later source change. Under the default source: image (Firefox), setting enabled: true alone does nothing to an already-built image — the browser is only installed by jailbee base build. jailbee <browser> on a container from an older image starts, prints its usual "Launching …" line, and exits 0: the launch is detached, so a missing binary fails silently inside the container, visible only in /tmp/jailbee-app-<name>.log or as missing in jailbee apps ls <container>. Run jailbee base build after enabling, the same as after changing source to image.

Key Type Default Description
default chrome | firefox | null null Which browser jailbee browser opens. null resolves at command time: the single enabled browser if exactly one is, otherwise the command asks you to set this or name one directly (jailbee chrome / jailbee firefox). Naming a disabled browser here is a config error.
url string | null null URL every enabled browser opens on launch, unless that browser sets its own url. Most repos have one app URL and no reason to write it twice. A browser cannot opt back out: url: null on a browser reads the same as not setting it, so it inherits — set the URL per browser instead when one should launch bare.
chrome BrowserConfig see below Google Chrome.
firefox BrowserConfig see below Mozilla Firefox.

Each of browsers.chrome and browsers.firefox is the same BrowserConfig shape:

Key Type Default Description
enabled bool false Master switch. When false, jailbee chrome / jailbee firefox exits 2 with a clear message, the browser is hidden from jailbee apps ls, autostart skips its launch, and the host_path auto-mount is omitted from effective_host_mounts.
source host | image host for Chrome, image for Firefox host RO-mounts an existing host install (see host_path); image installs the browser into the golden image during jailbee base build — no host install needed. Changing source needs a re-run to take effect: jailbee base build for image, jailbee apply for host. Firefox defaults to image because on Ubuntu the host's Firefox is a snap and /snap/firefox is not usefully mountable into a container; Chrome defaults to host, matching how it has always worked.
host_path path | null /opt/google/chrome for Chrome, null for Firefox Host path RO-mounted into the container when source: host (must be null under source: image). The container-side mount target is hardcoded per browser (/opt/google/chrome, /opt/firefox) — browsers.py's BROWSER_BINARIES map is what actually invokes the binary there. Override for a non-standard install (e.g. a chromium dir); null disables the auto-mount. Ignored when enabled: false. A manual host_mounts entry with a matching container: wins. Firefox has no default here because its host install is normally a snap — but setting host_path on Firefox without also setting source implies source: host, so naming a real install is enough.
url string | null null URL this browser opens on launch, overriding the shared browsers.url. null (default) inherits browsers.url, and means no URL when that is unset too. jailbee chrome <name> <URL> / jailbee firefox <name> <URL> override both per-call.
dark_mode bool false Force a dark theme — asymmetric between the two browsers. Chrome gets --force-dark-mode --enable-features=WebContentsForceDark, which darkens page content as well as the browser chrome. Firefox has no equivalent flag, so it gets GTK_THEME=Adwaita:dark instead, which darkens the browser UI only — pages render exactly as the site sends them; forcing dark page content in Firefox is an extension's job, not jailbee's.
autostart bool false Launch this browser after autostart steps. No-op if no graphical session is detected, or when enabled: false.

Each enabled browser gets its own per-container profile pool (chrome-profile / firefox-profile — see pooled_caches), so two containers running the same browser never fight over one profile directory.

The top-level chrome: block from before 1.3.0 still works, folded into browsers.chrome at load time with a deprecation hint on stderr naming docs/config.md — once per process, however many times the config is loaded. Unlike the agents/claude legacy alias, defining both is not an error: an explicit browsers.chrome overlays the folded legacy block field-by-field, so a half-migrated config behaves the way the newer spelling says. It is removed entirely in 2.0.0, alongside .gie/config.yaml — migrate to browsers.chrome.

The fold runs on the merged global+repo dict, so browsers: wins over chrome: regardless of which layer each one sits in — a browsers.chrome block in ~/.config/jailbee/global.yaml overlays a repo's legacy chrome: block, not the other way round. Migrate the global layer last, or a repo still on the old spelling silently loses its overrides.

apps

User-defined GUI applications beyond the built-in browsers and JetBrains IDE — an AppImage, a vendor binary, a wrapper script. A mapping keyed by app name; each entry resolves to the same AppSpec shape the builtins use, so jailbee apps ls, autostart and the dashboards handle a user-defined app identically to Chrome or the IDE. No pool and no mount is generated for an apps: entry — that YAGNI call is deliberate: if an app needs a per-container profile pool or an auto-mount, model it as a builtin instead.

Key Type Default Description
command list[string] required Container-side command to run. A string is split with shell quoting rules; a list is taken as-is. The first element is an absolute container path or a name on the container's PATH — never resolved on the host. A repo layer's value replaces the global layer's (see Layering below).
args list[string] [] Extra arguments appended after command. Arguments passed on the jailbee apps run command line are appended after these. A repo layer's entries append to the global layer's.
cwd string "repo" Working directory inside the container: repo (the checkout), home (the dev user's home), or an absolute container path.
env map[string, string] {} Extra environment variables, merged over the GUI environment jailbee already supplies (HOME, DISPLAY, WAYLAND_DISPLAY, XDG_RUNTIME_DIR).
description string "" One-line summary shown in jailbee apps ls.
top_level bool false Promote this app to a top-level command, so jailbee <name> launches it directly instead of jailbee apps run <name>. A built-in command of the same name always wins; a name that collides with one is a config error (jailbee config validate reports it, naming the fix: rename the app, or set top_level: false and use jailbee apps run <name>).
autostart bool false Launch this app after autostart steps complete.
apps:
  figma:
    command: /opt/figma-linux/figma-linux
    top_level: true
    description: "Figma desktop app"

With the above, jailbee figma [args…] launches it in the container this branch would attach to, and jailbee figma --container <container> [args…] names a different one. Both are literally jailbee apps run figma … — the promotion only drops the apps run, and everything after the app name is passed through untouched. Arguments that start with a dash need a -- separator, exactly as jailbee apps run does: jailbee figma -- --ozone-platform=wayland.

A Chromium-based app sandboxes itself only if AppArmor has a profile for its path, both on the host and in the container. On a host that sets kernel.apparmor_restrict_unprivileged_userns=1 (Ubuntu 24.04 and later), Incus stacks two AppArmor labels on every container process: the host's own and the container's. Each label attaches to a profile by the binary's path, and both must allow the app to create a user namespace. Ubuntu's apparmor package, which the golden image installs and the host already has, ships profiles for Chrome, Chromium, Edge, Brave, VS Code, Slack and Firefox at their standard install paths, so those apps are covered on both sides.

An app at any other path, such as the Figma binary above, falls back to the unprivileged_userns profile. That profile lets the app create a user namespace but gives it no capabilities inside it, so Chromium's sandbox aborts with The SUID sandbox helper binary was found, but is not configured correctly. Don't reach for --no-sandbox. Instead, write a profile modelled on /etc/apparmor.d/chrome for the app's container-side path, and load it in both places:

abi <abi/5.0>,
include <tunables/global>

profile figma /opt/figma-linux/figma-linux flags=(unconfined) {
  userns,
  @{exec_path} mr,
}
  • On the host: save it as /etc/apparmor.d/figma and run sudo apparmor_parser -r /etc/apparmor.d/figma. The host resolves the path as the container sees it, so the host doesn't need a binary at that path.
  • In the container: write the same file from an install.d/ snippet so the golden image carries it. apparmor.service loads it on every boot.

A profile on one side only leaves the sandbox broken.

jailbee figma <container> does not name a container. apps run takes the container as an option, not as a positional, so a bare name there is appended to the app's own arguments: the app starts in the default container and receives <container> on its command line, with no error. Use --container.

Note the asymmetry: jailbee apps run takes the app name as its first positional and the container behind --container, while jailbee chrome [container] [url] takes the container as its first positional. The two shapes can't be unified — jailbee apps run allows an optional app name, an optional container and a variadic list of extra arguments, and three positionals with an optional one in the middle cannot be told apart reliably.

An app name must match [a-z0-9][a-z0-9._-]* — it becomes a command word, a log-file path segment (/tmp/jailbee-app-<name>.log), and a dashboard action verb, so it is restricted to characters none of the three need quoted.

jailbee apps ls [<container>] lists every app this config can launch — builtins first, then apps: entries, in that order regardless of YAML key order. Without a container it is configuration only; name one to add a STATUS column that actually probes each app inside it (present / missing).

Layering. apps: is an ordinary merge-layer key, so an app defined in ~/.config/jailbee/global.yaml merges with a repo's apps: per app name, and within one app per key — a repo can set autostart: true on a host-wide app without restating its command. One key breaks the general "lists append" rule on purpose: a repo layer's command replaces the global layer's rather than appending to it, because a second binary path would otherwise become an argument to the first. args still appends, which is what lets a repo add one flag to a host-wide app.

agents

Generic hook for terminal coding agents — Claude Code plus five untested templates (codex, gemini, aider, opencode, grok), or one you define yourself. A mapping keyed by agent name, valid at both this file and ~/.config/jailbee/global.yaml, and it merges over a shipped preset (deep-merge — see Merge rules above) rather than needing every field spelled out. Full mechanism, the preset table, the "which paths to share" rule, and a worked example live in Generic agent support — this entry is the schema reference.

Field Type Default Description
enabled bool false Master switch: gates the shared mount, the strict-mode egress add, install/update at jailbee new time, and the jailbee doctor shared-dir check.
autostart bool false Launch command in a background autostart tmux window. Requires enabled: true.
command string "" The command line the autostart window execs; also the default source for install_check. Required when enabled: true.
install string | null null Shell command run at jailbee new time when install_check fails.
install_check string | null null Probe deciding install-vs-update. Defaults to command -v <first token of command>.
update string | null null Shell command run at jailbee new time when install_check succeeds and auto_update is true.
auto_update bool true false leaves an existing install untouched; a missing one is still installed.
install_network strict | loose strict Network mode for the install/update step only.
shared list of {subpath, path, type, seed} [] Bind mounts from <shared_dir>/<subpath> to <path>. type: dir (default) or file; seed (file only) is written once if the target is absent.
egress_allow list[string] [] Strict-mode allowlist entries added while this agent is enabled. Same grammar as top-level egress_allow.
env map[string, string] {} Env vars passed to the install/update step and the autostart launch step.

An agent name that matches one of the six shipped presets is deep-merged over that preset (preset → global.yaml → repo, same append/reset rules as every other list field); any other name is used as-is with no preset base. jailbee config validate additionally rejects a name outside [a-z0-9-]+, enabled: true with an empty command, autostart: true without enabled: true, and a shared subpath that collides with a built-in shared subdir or with a different mount target another agent already claimed.

agents:
  codex:
    enabled: true
    autostart: true

claude

agents.claude is the preferred spelling of this block. The top-level claude: key documented below is a supported legacy alias — moved to agents.claude at config-load time, before validation. Defining both claude: and agents.claude in the merged config is a ConfigError. Everything below applies identically under either spelling, and claude also carries the generic agents fields from the table above (install, update, install_check, install_network, shared, egress_allow, env) — not repeated here since they mean the same thing for every agent. See Generic agent support for the short version of this same note.

Claude Code CLI integration. The schema default is disabled, so a repo with no claude:/agents.claude block anywhere gets no Claude Code. Opt-in belongs in ~/.config/jailbee/global.yaml — and the template written by jailbee config init --global already carries claude.enabled: true (or agents.claude.enabled: true), so the usual first-run path turns it on. Delete or flip that block to keep Claude Code out.

Key Type Default Description
claude.enabled bool false Master switch. When true, JailBee mounts <shared_dir>/claude~/.claude and <shared_dir>/claude-install~/.local/share/claude as shared caches, auto-extends strict-mode egress_allow with api.anthropic.com:443 + code.claude.com:443 + claude.ai:443 + downloads.claude.ai:443 (the last two cover the install.sh bootstrap and the native CLI's self-update), creates an empty <shared_dir>/claude on jailbee init, and includes it in jailbee doctor checks. Claude Code's global config (.claude.json) lives inside the shared ~/.claude mount: the golden image exports CLAUDE_CONFIG_DIR=$HOME/.claude, and Claude Code reads (CLAUDE_CONFIG_DIR || $HOME)/.claude.json. Host ~/.claude is not read — Claude Code runs its onboarding flow inside the first container from a clean state (unless claude.seed_onboarding adopts a login the repo's credential group already holds), and subsequent containers in the same repo inherit that state via the shared cache.
claude.plugins_enabled bool true When true (and claude.enabled is true), also auto-extends egress_allow with the GitHub + npm hosts Claude Code's plugin marketplace, skills and SessionStart hooks reach (github.com, api.github.com, raw.githubusercontent.com, objects.githubusercontent.com, codeload.github.com, registry.npmjs.org). Set to false to keep the API reachable while blocking marketplace traffic. Has no effect when claude.enabled: false.
claude.autostart bool false When true (requires claude.enabled: true), jailbee appends a synthetic claude window to the autostart tmux session on every container start; the first jailbee tmux <c> lands in that window (later attaches keep the window you detached from). validate_runtime rejects autostart: true with enabled: false.
claude.command string "claude" Command line executed in the claude autostart window — override to pass flags (e.g. claude --dangerously-skip-permissions) or an env-prefix wrapper. Ignored when claude.autostart is false.
claude.auto_update bool true When true, jailbee new runs claude update inside the container so the shared install advances to the latest release. When false, an existing install is left untouched, but a missing one is still installed. Has no effect when claude.enabled: false.
claude.install_jailbee_skills bool true When true (requires claude.enabled: true), jailbee new and jailbee apply copy JailBee's bundled Claude skills (jailbee-usage, jailbee-repo-setup) into <shared_dir>/claude/skills/ so the in-container Claude understands jailbee. Host-side file copy only — no network. Has no effect when claude.enabled: false. The pre-1.0 name claude.install_gie_skills was retired in 1.1.0: a config still using it fails to load with an error naming this key.
claude.seed_onboarding bool true When true (requires claude.enabled: true), jailbee init / jailbee apply mark a fresh <shared_dir>/claude/.claude.json as already onboarded (hasCompletedOnboarding) and accept the trust dialog for the repo's in-container path, but only when this repo's credential group (see claude_credentials) already holds a login. Claude Code's first-run wizard is gated on that flag alone and never inspects the mounted credential, so without this every new container — and every scratch directory, which has no repo config to inherit state from — asks for a /login the shared credential has already answered. With no shared login there is nothing to adopt and the wizard runs as before, which is what walks the user through the login that does have to happen. A config home Claude Code has already written is never touched. Has no effect when claude.enabled: false.
claude.ai_pr_description bool true When true (and claude.enabled is true), jailbee pr generates the PR title and body by invoking Claude inside the container, showing a spinner while it runs. Falls back to commit-subject title + placeholder body on any Claude failure with a warning. Pass --no-ai to opt out per-invocation without changing config. Has no effect when claude.enabled: false.
claude.ai_pr_branch bool true When true (and claude.enabled is true), jailbee pr asks the in-container Claude to propose a convention-following PR head branch name when opening a new PR. Has no effect when claude.enabled: false.
claude.ai_pr_model string | null "sonnet" Model passed to claude --model when generating the PR text. Writing a description is a bounded job, and pinning it means the generation does not compete for the same budget as the coding work that just happened in the container. Accepts an alias (sonnet, opus, haiku) or a full model ID; null omits the flag so the container's own default model applies. haiku works but has a smaller context window, so a large cumulative diff may not fit. Rejected at load if it is not a single whitespace-free token. Has no effect when claude.enabled: false or claude.ai_pr_description: false.
claude.pr_prompt string | null null Project-specific PR-writing instructions, usually a YAML block scalar in a repo's .jailbee/config.yaml. Embedded in JailBee's own prompt as a delimited section that outranks the generic title/body guidance, so a project can dictate the shape of its descriptions — but it is placed before the JSON response contract, which it cannot override. Whitespace-only is treated as unset; capped at 20 000 characters. Has no effect when claude.enabled: false or claude.ai_pr_description: false.
claude.ai_pr_timeout int 600 Seconds jailbee pr gives the in-container Claude to produce the PR text before falling back to a placeholder. Generation is an agentic run, not one model call — it reads the log, the cumulative diff, the PR template, the branch's spec and the CI config across a dozen-plus turns, so the cost scales with the repository, not just with the diff. Measured in JailBee's own repo on a 21-file/+940 diff: 109 s. Raise it for a large tree, or when claude.pr_prompt asks for work that takes longer. Must be positive — to switch generation off use claude.ai_pr_description: false. Has no effect when claude.enabled: false or claude.ai_pr_description: false.

Example global config:

claude:
  enabled: true
  plugins_enabled: true

Encoding a project's PR standard

jailbee pr already reads .github/pull_request_template.md, the spec or issue a branch implements, and CONTRIBUTING.md / CLAUDE.md / AGENTS.md before writing anything. claude.pr_prompt is for the rules that live in none of those files — commit them to the repo's .jailbee/config.yaml so every container generates descriptions the same way:

claude:
  pr_prompt: |
    Body sections, in this order and with these exact headings:
      ## Why      — the user-visible problem, one paragraph, no implementation
      ## What     — bullets, each naming the file or symbol it changed
      ## Testing  — the commands you actually ran, verbatim
    Never use the word "comprehensive". Link the Jira ticket from the branch
    name as `[ABC-123](https://example.atlassian.net/browse/ABC-123)`.

These instructions win over JailBee's generic guidance where the two disagree, which is why the block cannot break generation: the response format Claude has to return is stated after it and stays JailBee's.

The claude shared caches are not present in the shared_caches: default list — they are auto-added by Config.effective_shared_caches() when claude.enabled is true. Manual entries in shared_caches: with names claude or claude-install suppress the auto-add (same precedent as effective_host_mounts).

terminal / terminal.kitty

When a developer runs jailbee shell / jailbee tmux from a kitty terminal on the host, TERM=xterm-kitty propagates into the container via incus exec. The base image's terminfo database doesn't ship the xterm-kitty entry, so curses-aware tools warn terminal is not fully functional and degrade. When active, this block RO bind-mounts the host's xterm-kitty terminfo file into every container so the entry resolves naturally.

Key Type Default Description
terminal.kitty.enabled "auto" | bool "auto" "auto" activates iff the host terminfo file can be located. true activates and fails jailbee config validate if no file is found. false disables the integration unconditionally.
terminal.kitty.host_terminfo_path path | null null Explicit host path to the xterm-kitty terminfo file. When null (default), autodetect probes /usr/share/terminfo/x/xterm-kitty, ~/.local/kitty.app/lib/kitty/terminfo/x/xterm-kitty, and ~/.terminfo/x/xterm-kitty in that order.

autostart

IDE and browser launch decisions live in jetbrains.autostart, browsers.<name>.autostart and apps.<name>.autostart (not here). The autostart block describes the shell work that runs inside the container during startup, organized into stages of chains of steps.

Top-level keys:

Key Type Default Description
on_create list[Step] | list[Stage] [] Run once after jailbee new provisions the container. Either a flat list of steps (legacy) or a list of stages — see below. Mixing both shapes in the same trigger is a config error.
on_start list[Step] | list[Stage] [] Run on every stopped→running transition: both jailbee new (after on_create) and jailbee start/restart. Put one-shot setup in on_create and recurring launches (dev servers, etc.) in on_start — don't duplicate. Same two shapes as on_create.
step_timeout int 600 Default per-step timeout in seconds (overridable per step).
env map {} Global env merged into every step (per-step env wins on key collisions).

Stages and chains

A stage is one milestone of a run. Stages execute in list order — a stage starts only once the previous one has fully finished. A stage owns the network profile and the mounts for all of its chains: it switches the network once on entry and restores it once on exit, attaches its optional_mounts once and detaches them once, and nothing inside its chains may touch either — that's what makes running several chains at once safe, since no chain can swap the profile or drop a mount out from under a sibling.

A stage's chains run in parallel with each other; a chain's own steps run one at a time, in order. Put two steps in the same chain when one depends on the other; put them in different chains (or different stages) when they don't.

When one chain's step fails (and that step doesn't set continue_on_error), every chain stops launching further steps — not just the one that failed — but steps already in flight in other chains are left to run to completion rather than interrupted; a half-finished install is worse than a finished one. The stage then fails once those in-flight steps have exited.

- stage: name              # required, unique within the trigger
  network: null            # strict|loose|null — switched once for the whole stage
  mounts: []                # optional_mounts keys, attached for the whole stage
  detach: false             # see "Detaching a run" below
  chains:                   # OR `steps:` — not both
    - name: backend
      steps:
        - { name: migrate, run: "pnpm db:migrate" }
        - { name: server, run: "pnpm start", background: true }
    - name: frontend
      steps:
        - { name: assets, run: "pnpm build:assets" }

steps: on a stage is shorthand for a single chain named main — the common case of one stage doing one thing in order:

- stage: install
  network: loose
  steps:
    - { name: deps, run: "pnpm install" }

is exactly:

- stage: install
  network: loose
  chains:
    - name: main
      steps:
        - { name: deps, run: "pnpm install" }

A stage that sets both chains and steps is a config error. A chain is {name, steps}name unique within its stage, steps the same Step schema as the flat form (below), run in list order. jailbee autostart status reports progress by stage and step, not by chain — a chain's name exists to keep chains apart in the config and doesn't appear in that output.

Each step is {name, run, ...}:

Key Type Default Description
name string required Identifier; unique across the whole trigger (not just its chain) — step names are tmux window names, and with parallel chains a colliding name can kill a running sibling's window.
run string required Shell command run as the dev user. Always cd'd into working_dir first.
network strict/loose/null null Legacy, flat form only — see Deprecated: step-level network/mounts below. Set on the enclosing stage instead.
mounts list[string] [] Legacy, flat form only — see the deprecation note below. Set on the enclosing stage instead.
env map {} Per-step env (merged on top of autostart.env).
working_dir string "" Path relative to repo_dir. Empty = repo_dir itself.
background bool false Run in a detached tmux window; do not wait for completion. Attach via jailbee tmux <container> to see output.
timeout int null (= step_timeout) Per-step timeout override in seconds.
continue_on_error bool false If true, a non-zero exit warns instead of aborting subsequent steps in its chain.

jailbee config validate additionally rejects: a duplicate stage name within one trigger, a duplicate chain name within one stage, a duplicate step name anywhere in one trigger (see name above), and a step name that collides with an autostarting agent's own tmux window name (see Generic agent support).

Detaching a run

detach: true on a stage means: this stage, and every stage after it in the same trigger, run in a background supervisor process instead of blocking jailbee new/jailbee start/jailbee restart. The first stage marked detach: true wins — repeating it on a later stage is legal but redundant. Everything before it still runs in the foreground and blocks as before, so the CLI hands you the session (shell, tmux, or just the prompt back, per after_new/--attach) right after the last blocking stage finishes, while the rest continues in the background.

--wait (run every stage in the foreground, ignoring detach: true — the pre-1.4 behaviour) and --no-wait (run only the first stage before handing over, whatever the config says) on jailbee new/jailbee start/jailbee restart override this per invocation; the two are mutually exclusive. See Commands for jailbee autostart status/cancel, which inspect and stop a detached run, and Security for what a stage's network switch means while it's running unattended.

A config with no detach: true anywhere behaves exactly as before: every stage blocks, in order, and there is nothing to inspect afterwards.

Two known gaps, both narrow:

  • jailbee destroy carries no guard against a detached run. stop refuses while a supervisor is still working unless --force is given, and restart refuses too with no --force at all; destroy does not check either way, so destroying a container mid-run tears it down out from under its own supervisor. Cancel the run first (jailbee autostart cancel) if you want a clean stop.
  • On jailbee new --background, GUI autostart apps launch after every deferred stage finishes, not at the attach boundary. The worker that keeps running the deferred stages in-process is the same one that would launch them, so they wait behind the whole detached run rather than coming up as soon as the session itself would be usable.

The reserved agents stage

jailbee generates one autostart step per agent with autostart: true (see Generic agent support) and needs somewhere to put them. The stage named agents is reserved for exactly this, but only in the stage form — a flat step literally named agents is just an ordinary step; the reserved name means nothing there, because a flat config's step names are user data, not a namespace jailbee claims.

  • Written explicitly{stage: agents, ...} in your on_start list — it may not carry chains/steps of its own (jailbee config validate rejects that as an error): jailbee fills it with the generated per-agent launch steps, one chain named main. Its own network and mounts are yours to set and are kept as written. When it is the first stage in the trigger to carry detach: true (and no --wait/ --no-wait override is in play), that flag behaves differently from an ordinary stage: instead of deferring the agent launch itself, it keeps the agents stage blocking — so the CLI still hands you a session with the agent already up — and defers everything after it instead. If an earlier stage already carries detach: true, the agents stage simply falls inside that deferred region like any other stage after the boundary, and its own detach (if set) changes nothing.
  • Left unwritten, jailbee inserts it itself at the effective attach boundary within on_start — immediately before whatever on_start stage would otherwise be deferred, or at the very end of on_start's stages when nothing in on_start itself detaches. Within on_start alone, that is the latest point that still runs before the session is handed over.

If on_create already deferred something, none of the above applies. A detach: true stage in on_create (or --no-wait splitting it) hands the session over before on_start is even reached in the foreground — on_start then runs entirely in the background supervisor that resumes it, agents stage included, regardless of where you put it or what its own detach says. In that case the agent's tmux window is created shortly after you attach, not before — the same as every other on_start stage.

An explicit stage: agents is dropped entirely whenever there is nothing to fill it with — not only under on_create (which never launches an agent at all), but also under on_start when no agent currently has autostart: true. If the dropped stage carried detach: true, that flag moves to whichever stage takes its place, so the split point you drew doesn't silently vanish.

Which window jailbee tmux (or --attach tmux) lands on is chosen by name on the first attach, not by tmux's own "most recently created" default — so the agent stage's position only decides when its window is created relative to the hand-off, not which window ends up focused. Later attaches keep the window you detached from; see Where the generated launch steps go.

Deprecated: step-level network/mounts

A step's own network/mounts are legal only in the flat form — the pre-stage shape jailbee still reads and runs exactly as before. Inside a stage they are a config error: move them to the enclosing stage, which is the level that now owns both.

 - stage: install
+  network: loose
   steps:
     - name: deps
       run: "pnpm install"
-      network: loose

A flat step that still sets network or mounts loads and runs exactly as before — jailbee new, jailbee apply and every other command are unaffected. jailbee config validate alone reports it, naming the (implicit) enclosing stage's key to move it to; that reported issue still makes jailbee config validate itself exit non-zero, so a CI check gated on it fails until you move the key, even though nothing else does. Removal is planned for 2.0.0.

Legacy compatibility: the flat form

A flat list of steps under on_create/on_start keeps working exactly as it always has — nothing needs converting. Internally, jailbee normalizes it into stages before running it: consecutive steps that share the same network collapse into one implicit single-chain stage, named after the first step in that run. This is the one observable behaviour change: a run of several steps sharing a network mode switches the profile once rather than once per step. Ordering, per-step mounts (still honoured, if deprecated), and everything else about a flat config are unaffected.

Where do my autostart steps run?

All steps run inside a container-local tmux session named autostart. One window per step. To attach interactively:

jailbee tmux <container>

Every step window is created in the background, so attaching while a run is still going does not drag your view from step to step. A step that fails keeps its window open with the output still on screen (remain-on-exit failed, set on that window alone); a step that succeeds closes its window, as do the windows you open yourself. Background steps (e.g. pnpm dev) keep running in their own window until the container stops.

Where does the autostart config come from?

In clone mode, jailbee new <branch> reads autostart from the target branch's committed .jailbee/config.yaml, at the exact commit it is about to clone — not from your checkout. The container runs the branch's files, so it runs the branch's startup steps. Every other key (mounts, network defaults, cpu/memory, container_prefix, and host-level keys like docker_registry_mirror, ls, dashboard) still comes from the checkout you run jailbee new from; a branch cannot change how the operator runs containers.

If the branch's autostart deviates from your checkout's, jailbee new prints a compact diff naming what it read from — the branch ref (refs/heads/feat/x) when cloning a local branch as-is, or a short sha with the branch name in parens (a1b2c3d4e5f6 (feat/x)) when the clone resolved to a pinned commit (origin-mode, a PR review, or an explicit --pr/commit checkout):

autostart config comes from a1b2c3d4e5f6 (feat/x), not your checkout:
  + on_create[migrate]
  - on_start[old-watcher]
  ~ on_create[build]: run changed
  ! step_timeout: 600 → 900
  ! env: NODE_ENV

Step names are trigger-qualified (on_create[build] vs on_start[build]) because the same name can exist under both triggers as distinct steps. A stage is named the same way but in angle brackets (on_create<setup>), because a stage and a step may carry the same name.

The privilege check is a separate comparison

That diff explains a surprise: why the container runs steps you don't have. Whether the branch gains anything by them is a different question, and it is answered against a different reference — the repo's reviewed baseline, refs/remotes/<upstream_remote>/<default_branch>, rather than your checkout:

branch autostart widens privileges beyond refs/remotes/origin/main:
  ⚠ network access 'loose' in: on_start[warmup]
  ⚠ attaches host mount(s): aws

Your checkout is one snapshot of one branch: it may lag the upstream, run ahead of it, or be an unrelated feature branch with local edits. Measuring privileges against it made the same jailbee new ask one developer and not another, and turned "my checkout is a few commits behind" into an escalation. The default branch on the upstream is what review and CI gate, so that is the baseline. If it carries no usable config, the check falls back to comparing against your checkout and says so in that line. When the baseline ref cannot be read at all — no such remote, or a default branch never fetched — that fallback is a genuinely weaker gate, so it is warned about rather than only noted in the line.

Two kinds of widening are reported, and they are weighed differently:

  • a step or stage attaching an optional_mounts entry the baseline does not attach — these are typically personal credential directories (~/.aws, ~/.m2), and the command lines that run while it is attached come from the same branch. This always asks for confirmation, defaulting to no; declining aborts the whole jailbee new with nothing created. Attaching a mount is what creates the asset — a credential directory the container would not otherwise hold — and no network mode protects against it.
  • a step or stage widening network access from strict to loose. This asks only for an untrusted head: jailbee new --pr N where the PR's head lives in a fork (isCrossRepository) — code nobody with push access to your repo has vouched for. Everything else warns and proceeds. Once the container runs the branch's code, strict is an egress allowlist of package registries and forges that all accept uploads, so it is no boundary against that code — while loose is the ordinary way a step installs dependencies.

A PR number is not the signal, deliberately: an internal PR's head is a branch in your own origin, byte-identical to what jailbee new <branch> would clone and pushed by someone who can already run code in your containers. Gating one spelling and not the other would ask about how the command was typed rather than about risk — and a question that fires routinely trains you to click through the mount one, which is the question that matters.

In both cases a brand-new step counts as widening, since the baseline has no counterpart to compare against. Everything else a step controls (run, env, working_dir, background, timeout, continue_on_error), and a stage's detach and chain layout, only warns: it is container-internal, and adds nothing beyond the code execution a cloned branch inherently has. A chain layout warns as ~ on_create<setup>: chains changed, which covers a chain renamed, re-split, or moving a step the two configs share — including a step moved from one stage to another, which does change the mode and mounts it runs with. A step that exists on one side only is left to its own +/- line instead, so renaming a step inside a stage does not also report the stage.

In the stage form, network and mounts live on the stage — a step inside one may not carry either — so that is where both the diff and the ⚠ line name them (on_start<warmup>), rather than repeating them on each of the stage's steps. Underneath, the two forms are matched step by step and compared on the mode and mounts each step effectively runs with. So moving an unchanged loose step into a loose stage is neither a widening nor a reported change: the only line it produces is the new stage itself.

--yes/-y accepts without asking (in addition to its original job of skipping the "branch already exists" prompt). --no-autostart skips the branch config entirely: none of its steps run, so there is nothing to diff or confirm.

With jailbee new --background the question is asked before the run detaches, in the terminal you are still sitting at — a detached worker has no stdin, so a question left for it could only ever be answered "no". Declining exits without creating a container or recording a job. The answer is pinned to the commit you were shown: if the branch moves between the confirmation and provisioning, the worker aborts naming the move instead of provisioning a config nobody saw. When there is no terminal to ask on at all (a script, CI), jailbee new says so and tells you to pass --yes.

A branch with no committed .jailbee/config.yaml falls back silently to your checkout's autostart — a branch need not define one. A branch config that exists but can't be used (invalid YAML, a validation failure, or an autostart step naming an optional_mounts key your host config doesn't define) warns and falls back the same way.

--mount and --no-clone are unaffected — they share the host working tree, so there is no distinct target branch to read from. jailbee start, jailbee restart, and jailbee apply always use your checkout's config; only creation reads the branch.

container_prefix

Key Type Default Description
container_prefix string derived from repo_root.name Prefix for all jailbee-owned Incus resources (containers, profiles, ACL). Must match [a-z0-9][a-z0-9-]*. Override only if repo_root.name doesn't match the regex (e.g. underscore, dot, or capital letter).

docker_registry_mirror.extra_registries

docker_registry_mirror:
  extra_registries:
    - 803520778560.dkr.ecr.eu-north-1.amazonaws.com
Key Type Default Description
extra_registries list[string] [] Extra registry hostnames this repo pulls images from but which rpardini does not cache out of the box. Entries must be bare hostnames, optionally with :port — no scheme, no path.

rpardini's image defaults cache only Docker Hub, registry.k8s.io, gcr.io, quay.io, and ghcr.io. Hostnames outside that set (notably AWS ECR — *.dkr.ecr.<region>.amazonaws.com) are CONNECT-tunneled without caching, so every jailbee new re-pulls those images from the internet. Listing them here pushes them into the mirror's REGISTRIES env on the next jailbee new / jailbee apply, after which pulls hit the rpardini cache on second run.

Mechanics: jailbee new and jailbee apply write the union of these entries into /etc/jailbee-registry-proxy.env inside the jailbee-registry-mirror container and restart jailbee-registry-proxy.service — only when the file actually changes, since a restart drops every pull in flight through the mirror. The mirror is host-global, so the set accumulates across repos — once added, a hostname stays until the mirror container is recreated.

The same file carries jailbee's host-global proxy tuning, written by jailbee registry up, jailbee new and jailbee apply whether or not the repo lists any registries: DISABLE_IPV6=true (nginx stops trying IPv6 upstreams the host may not be able to route) and PROXY_CONNECT_TIMEOUT / PROXY_CONNECT_CONNECT_TIMEOUT of 5s instead of nginx's 60 s, so one unresponsive upstream address cannot hold a pull past dockerd's patience. These three keys are jailbee's and a hand-edited value is put back; any other key in the file (rpardini's AUTH_REGISTRIES, say) is preserved.

new

Policy for what state jailbee new starts a new container from when the container's branch does not already exist in the source repo (the "default-branch fallback" path).

new:
  clone_from: origin   # 'origin' (default) | 'local'
  autofetch: true      # default true
  background: false    # default false
  submodules: true     # default true
Key Type Default Description
clone_from enum origin With origin, the new container is checked out at refs/remotes/origin/<default_branch> on the host, so the working tree reflects the upstream tip. With local, the classic behaviour applies: refs/heads/<default_branch> (whatever the host's local default branch points at).
autofetch bool true When true and clone_from='origin', jailbee new runs git fetch origin <default_branch> on the host before resolving the ref, so a stale host doesn't propagate into the container. Set false to skip and rely on whatever the host already has.
background bool false Run jailbee new detached in the background by default. Overridable per-invocation with --background / --no-background. An explicit --attach shell/--attach tmux, --tmux, or --shell also forces foreground, since a detached run has no terminal to attach; --attach none / --no-attach don't, and combine fine with --background.
submodules bool true Initialize the superproject's git submodules (recursively, offline from the host bind mount) in the new container. Set false to skip.

Scope of clone_from / autofetch: these two apply only to the default-branch fallback path — i.e. when no --base is given and the requested branch does not yet exist in the source repo. --base <X> always uses refs/heads/<X> (local) by design, since the user has explicitly picked a local starting point. --pr <N> performs its own fetch (gh-driven) and is unaffected. background and submodules apply to every jailbee new invocation regardless of the starting-point path.

Errors:

  • If autofetch=true and the fetch fails (no network, ACL denial, bad credentials, …), jailbee new aborts before touching Incus state. Resolve the underlying issue or set autofetch: false.
  • If clone_from='origin' but refs/remotes/origin/<default_branch> does not exist in the host repo, jailbee new aborts. Fetch first, or set clone_from: local.
  • A repo with no such remote at all is not that case: origin is only a fallback name (see upstream_remote), so a local-only repo — git init, no remote, the ordinary shape of a scratch directory — has no upstream tip to prefer. jailbee new uses refs/heads/<default_branch> there without being told to, and aborts only when the branch exists neither locally nor on a remote, naming --base as the way to start from a branch the repo does have.

destroy

Key Type Default Description
background bool false Run jailbee destroy detached in the background by default. Overridable per-invocation with --background / --no-background.

boot

Key Type Default Description
background bool false Run jailbee start and jailbee restart detached in the background by default. Overridable per-invocation with --background / --no-background.

One key covers both commands: what makes either slow is the autostart run that follows the boot, and it is the same run. A detached boot is refused while another background job for that container is still live — two of them would interleave their autostart steps.

after_new

Key Type Default Description
after_new "shell" | "tmux" | "none" "none" After a successful jailbee new, automatically attach to the new container. "tmux" attaches to the autostart tmux session (creating it on demand), "shell" opens an interactive bash login shell, "none" (default) returns to the host prompt. Override per-invocation with jailbee new --attach <mode>, the --tmux / --shell shorthands, or --no-attach. Unlike --attach shell/--attach tmux, --tmux, and --shell — which force foreground — this config default yields silently to a background run, same as --attach none / --no-attach.

confirm

confirm:
  auto_target: true    # confirm push/pull/checkout when jailbee picks the container

jailbee git push / pull / checkout settle on the single existing container without showing a picker. With confirm.auto_target on (the default) they first print a plan block — both branch names, both tips, the action — and ask [Y/n]. Declining aborts before anything reaches the container or a host branch (though on the push path, the host's origin/<source> fetch already ran). Per-invocation overrides: --confirm / --no-confirm. Off a TTY, pull/checkout print the block and only skip the prompt; push requires an explicit name off a TTY in the first place, so it never reaches this confirmation there. See Confirming an auto-picked container.

pull

Controls jailbee git pull's post-merge cleanup prompts after a successful merge from a container's branch into the container's recorded base branch (user.jailbee.base_branch, set at jailbee new time); override the merge target for a single invocation with --into <branch>.

Key Type Default Meaning
destroy_container "prompt" | "always" | "never" "prompt" Whether to destroy the container after a successful merge.
delete_branch "prompt" | "always" | "never" "prompt" Whether to delete the merged local host branch.
tags "none" | "reachable" | "all" "reachable" Which tags travel from a container to the host — governs jailbee git fetch, checkout and pull alike, not just pull. "reachable" is what git's automatic tag-following has always done here (lightweight and annotated tags both); "all" mirrors every tag the container has; "none" leaves the host's refs/tags/* untouched. No setting ever re-points a tag that already exists on the host. See Tags.
ff "never" | "auto" | "always" "auto" How jailbee git pull merges the container's branch. "auto" fast-forwards when the host branch is strictly behind and writes a merge commit otherwise (the behaviour before 1.4.0 was always a merge commit — set "never" to keep it); "always" demands a fast-forward and fails on divergence. A target branch that isn't checked out is fast-forwarded at ref level regardless of this key. See Fast-forward policy.

--cleanup on the CLI forces both cleanup keys to always; --no-cleanup forces both to never. Cleanup failures are warnings, not errors. --ff/ --no-ff override ff for one run; --tags/--follow-tags/--no-tags override tags for one run (mutually exclusive within each pair).

Example (~/.config/jailbee/global.yaml):

pull:
  destroy_container: prompt
  delete_branch: prompt
  tags: reachable
  ff: auto

Migration note: this block was previously called merge:. A config file that still uses merge: fails to load with a clear error naming both the old and new key and the file path. Rename the block to pull: to fix.

push

Controls jailbee git push's default behavior when called with partial arguments. Each key may be "ask" (open an interactive prompt) or a concrete value; default_action defaults to "ask", default_source defaults to "base".

Key Type Default Meaning
default_action "merge" | "rebase" | "plain" | "ask" "ask" What to do after pushing the ref. "plain" is transport-only (no merge/rebase).
default_source "default-branch" | "current" | "base" | "ask" "base" Which branch to push. "base" resolves to each container's recorded base branch (user.jailbee.base_branch), so the host pushes exactly what the container was branched from. "default-branch" always uses the repo's default branch, regardless of the container's base. "current" uses git symbolic-ref --short HEAD. "ask" opens an interactive picker every time.
push_from "origin" | "local" "origin" Which copy of that branch to push. "origin" sends refs/remotes/origin/<source> and falls back to refs/heads/<source> when the branch has no upstream copy; "local" reverses the order.
autofetch bool true Run git fetch origin <source> on the host before resolving the ref, so the remote-tracking copy is current. Only applies in push_from: origin mode. Best-effort — the push proceeds with the refs already present; a failure is reported only when the origin ref is what ended up travelling (and may therefore be stale), not when resolution fell back to the local branch because the source simply isn't on origin.
tags "none" | "reachable" | "all" "none" Which tags travel from the host into a container. "none" sends none; "reachable" sends the tags reachable from the pushed branch (computed with git tag --merged, not --follow-tags, so lightweight tags survive); "all" mirrors every tag the host has. Never re-points a tag that already exists in the container, and never reaches the GitHub origin — jailbee pr is unaffected. See Tags.
ff "never" | "auto" | "always" "auto" How jailbee git push --merge merges inside the container. "auto" fast-forwards when the container is already on the pushed branch and writes a merge commit otherwise, asking first (TTY) or erroring (no TTY) if that fast-forward turns out to be impossible; "always" demands a fast-forward and fails on divergence; "never" always writes a merge commit. See Fast-forward policy.

CLI flags (--merge, --rebase, --plain, --from, --current, --from-origin, --from-local, --fetch/--no-fetch, --ff/--no-ff, --tags/--follow-tags/--no-tags) always win over the configured defaults. With "ask", the command opens a questionary prompt; in a non-TTY environment, the command errors and points at the relevant config key.

The dashboards follow the same rule from the other side. jailbee dashboard hands over the real terminal, so the questionary prompt appears exactly as it would on the command line. The Qt dashboard cannot — its child process has no stdin — so it asks in a dialog instead and passes the answer as a flag, and only for a key that is "ask": pin default_action or default_source and the GUI stops asking about it. Its source dialog offers the container's recorded base branch and the host's checked-out branch, the two choices it can express without reading the host repo; for "default-branch", set default_source in the config rather than answering per push.

Why push_from defaults to origin

git fetch updates refs/remotes/origin/<branch>; the local refs/heads/<branch> only moves on git pull. For any branch you do not check out on the host — typically the base branch a container is pushed with — the local ref is stale exactly when you just fetched. With push_from: origin (plus autofetch), jailbee git push sends the upstream tip, matching what jailbee new already does via new.clone_from: origin.

This also protects jailbee ls: when the source equals the container's base branch, the push force-updates refs/jailbee/base/<base>, so pushing a local base that trails origin would move that anchor backwards and inflate the AHEAD counts.

--current (or default_source: current) always resolves locally regardless of these keys: the host's checked-out branch is the work in progress, so the local ref is the fresher one by construction.

--pr ignores them entirely — jailbee fetches the PR head into refs/jailbee/pr/<N>/head and pushes exactly that ref, so there is no local-vs-origin choice to make (passing --from-local/--from-origin with --pr is rejected).

Use --from-local when the host has commits not yet pushed to origin. jailbee warns when it pushes an origin ref while the local branch holds commits that ref lacks, so nothing is dropped silently.

Lives in either ~/.config/jailbee/global.yaml (user-wide) or <repo>/.jailbee/config.yaml (per-repo). The repo file overrides the global file via the standard deep-merge pipeline.

Example (~/.config/jailbee/global.yaml):

push:
  default_action: merge
  default_source: current

With the above, jailbee git push feat-foo runs git merge in the container using the host's currently checked-out branch as the source — no prompts, and no fetch (current implies the local ref).

ls: / dashboard: — remembered columns

Which columns jailbee ls and the dashboards show, by default.

ls:
  fields: null      # explicit ordered list, or null for the built-in default
  hide: []          # subtractive; applies only when `fields` is null

fields, when set, wins outright: naming a column is a request for exactly that column, in that order, even one that is off by default (local_diff, local_count, …) or would otherwise be hidden by a dynamic rule (e.g. pr with no container carrying one). hide is subtractive and only prunes the built-in default set — a dynamic rule such as pr's "show only when something has one" still applies to a hidden-by-config column, unlike fields. This one rule is implemented once, in table_format.apply_column_config, and used by jailbee ls; the deprecated dashboard: block followed the same rule for its one-time import into view_prefs (see below).

The two views have different built-in defaults. jailbee ls is a one-shot listing and stays narrow: NAME, BASE, STATE, CREATED, NETWORK, WT, AHEAD ±, ↑, MERGE. The dashboards add MEM, CPU and DOING, because a live number is worth its width in a view that refreshes and is a stale sample in one that does not — CPU and DOING are rates and have no value at all in a single reading, so ls takes a second one when you name either in --fields. IP is off in both — enable it in the dashboard settings UI, or ask for it from ls with --fields ip.

Four columns are dynamic and appear only when they have something to say: job (a background job is running), ttl (a container is in loose mode), pr (a container tracks a PR) and mode (a mount-mode container exists — on a clone-only host the column would be a constant).

Table output only. jailbee ls --format json always emits its own built-in field set (FieldSpec.default_json), regardless of ls.fields/ls.hide — a personal display preference in global.yaml must never silently narrow machine-readable output a script depends on. An explicit --fields flag on the command line still wins in every format, table or JSON.

Allowed names (also the jailbee ls --fields vocabulary): name, full_name, repo, mode, base, state, created, job, network, ttl, loose_until, ip, memory_limit, mem, wt, ahead_diff, ahead_count, conflict, local_diff, local_count, git_status, pr.

Three things are problems: an unknown name (reported with the allowed set listed), fields: [] (a table with no columns at all — write fields: null if you want the built-in default set back), and the same name twice in fields (it would render that column twice). None of these three is fatal at load time any more, in either file — a column choice is a personal display preference, and a typo in it must never break an unrelated command. Both ~/.config/jailbee/global.yaml and a repo's .jailbee/config.yaml recover the same way: an unknown name is dropped, a duplicate collapsed to its first occurrence, and an empty (or emptied-by-dropping) fields reset to the built-in default set (hide is never reset this way — an explicitly empty hide is a real value, not the same footgun). The command proceeds with whatever remains valid, and the fix is printed as a warning naming the file it came from, so a global-layer fix isn't confused with a repo-layer one. Either way, jailbee config validate is where all three are still reported as errors, with the allowed set listed for the unknown-name case — the one command whose job is telling you what's wrong.

ls: exists in ~/.config/jailbee/global.yaml and in a repo's .jailbee/config.yaml, merged the same field-by-field way as loose_auto_revert: the repo's block overrides the global one per field (setting only hide in the repo still inherits the global fields, and vice versa). Note the key is not part of the general deep-merge pipeline used by the rest of the file — that pipeline appends list values, which would concatenate the two fields lists instead of replacing one with the other. A repo block that names fields replaces the global list outright; fields: null in the repo discards the global list and restores the built-in default set. Column choice is a personal preference, so the normal home is global.yaml; a repo that sets the block does so for everyone working in that repo — deliberate, and rare.

--fields on the CLI beats both ls: blocks outright — this is a remembered preference, not a lock.

The dashboards remember their own columns

jailbee dashboard and jailbee gui do not read a dashboard: block. Each remembers its own columns and its own folded repo groups, because a live view can own the state you are looking at:

  • In the TUI, press F2 (or S) for the settings overlay: / moves, Space toggles, Tab switches between Fields and Repos, Esc closes. Changes apply immediately — the table stays on screen behind the panel.
  • In the GUI, use View ▸ Columns.

The two are independent on purpose: a wide Qt table and a narrow TUI is a supported setup. State lives in state.sqlite's view_prefs table, one row per front-end — machine-written, so it stays out of your hand-edited config.

Enabling a column means "show it when it has something to say": the four dynamic columns (job, ttl, pr, mode) still appear only when they apply, and the overlay marks them so. This differs from ls --fields, where naming a column forces it on — there a name is a one-shot request, here it is a standing preference.

dashboard: is deprecated. The key is still accepted, so an existing config keeps loading, but it is ignored: it is imported into each front-end's own settings the first time you open that dashboard after upgrading, and can be deleted once both have been opened at least once. jailbee config validate says so. Only ~/.config/jailbee/global.yaml is imported this way — the setting is personal and applies in every repo, so a repo-level dashboard: block is reported and dropped rather than seeded. ls: is unaffected and still lives in config.

The Qt dashboard's Compact card style is the one exception: it renders a hardcoded selection — name, state, mode/base/network, a job badge and a folded wt/ahead_diff/ahead_count/conflict summary — so a configured column outside that set (local_diff, say) reaches the tree and the Grid card style but never Compact. Switch card style to see it.

Computed attributes

The Config object exposes four attributes set at load time, not from YAML:

  • repo_root — directory containing .jailbee/.
  • upstream_remote — which of the repo's git remotes jailbee treats as the upstream. See Which remote is the upstream? below. Fallback origin.
  • default_branch — auto-detected. See Which branch is the default? below. Fallback main.
  • container_prefix — defaults to repo_root.name, overridable via the optional container_prefix: YAML key. Used as the prefix for every jailbee-owned Incus resource (containers, profiles, ACL).

Which branch is the default?

default_branch is more than jailbee new's starting point when the requested branch does not exist yet: it is also the comparison base for jailbee ls's ahead/behind columns, for the container diff, and for a jailbee pr that names no base. It therefore has to be stable — a value that followed whatever the host has checked out would silently re-anchor all of those — so jailbee reads something that exists rather than guessing a name, taking the first of:

  1. refs/remotes/<upstream_remote>/HEAD — the project's own answer, written by git clone from what the server reports;
  2. refs/remotes/<upstream_remote>/main, then .../master — a repo whose branches were fetched by hand has no symref to read;
  3. the local refs/heads/main, then refs/heads/master;
  4. the currently checked-out branch — the only evidence left in a repo that follows neither convention;
  5. the literal main, for a detached HEAD, a repo with no commit yet, or no git at all.

Steps 2-4 are what make a local-only repo (git init, no remote) work: upstream_remote falls back to the name origin whether or not such a remote exists, so a guessed main used to name a branch that existed nowhere — and jailbee new then refused to create any container in that repo. The conventional names outrank the current branch deliberately: a checkout sitting on feature/x must not make feature/x the diff base for every container of the repo.

Which remote is the upstream?

origin is only the name git clone picks by default, and git remote rename is an ordinary thing to do. jailbee therefore resolves the name instead of assuming it, once per invocation, taking the first of:

  1. the sole remote, when the repo has exactly one;
  2. origin, when it exists;
  3. remote.pushDefault;
  4. the current branch's branch.<branch>.remote;
  5. the one remote carrying a refs/remotes/<remote>/HEAD symref — the signal that survives both a rename and a branch that was never pushed.

A candidate naming a remote that no longer exists is skipped, so a stale remote.pushDefault cannot win.

origin sits ahead of every other signal on purpose: a repo that has one behaves exactly as it always did. In particular, a fork checkout where origin is your fork and branches track the canonical repo keeps pushing to the fork.

There is no config key for this — git already holds the answer, and a submodule may answer differently from its superproject (each is resolved against its own directory). If jailbee cannot tell, it falls back to the literal origin and jailbee doctor reports the ambiguity; disambiguate with git config remote.pushDefault <name> or by giving the branch an upstream.

github

GitHub CLI (gh) integration. When enabled, jailbee:

  • Opens api.github.com:443 in the strict-mode egress allowlist.
  • Injects GH_TOKEN into the container at autostart via /etc/profile.d/jailbee-github.sh so login shells (and AI agents launched through them) authenticate without gh auth login.
  • Runs jailbee doctor checks for token presence, perms, and PAT shape.
github:
  enabled: true
  api_tokens:
    sampleapp:     github_pat_AAA...   # one entry per GitHub owner
    personal-tool: github_pat_BBB...

Keys are container_prefix values from .jailbee/config.yaml; each container picks the token matching its prefix. One entry per GitHub resource owner (fine-grained PATs are scoped per-owner).

Placement constraint: the github block must live in ~/.config/jailbee/global.yaml. Placing it in any repo's .jailbee/config.yaml is rejected at load time — committing a repo file with a token would leak it.

Permissions: when api_tokens is non-empty, ~/.config/jailbee/global.yaml must be mode 0600. jailbee config validate / load_config fail loudly otherwise; run chmod 600 ~/.config/jailbee/global.yaml after editing.

Token shape: prefer fine-grained PATs (github_pat_*) scoped to "Only select repositories" with Contents:Read, Issues:RW, Pull requests:RW, Metadata:Read. Classic PATs (ghp_*) get a doctor warning because they cannot be scoped per-repo.

Field defaults:

Field Type Default Notes
enabled bool false Master switch. Opt-in via global.yaml.
api_tokens dict[str, SecretStr] {} Map from container_prefix to PAT. Values masked in repr(cfg) to avoid accidental log leaks.

enabled: true with empty api_tokens is rejected at load time. A repo whose container_prefix is not in api_tokens produces an info-level doctor message ("no token configured") — gh still runs but cannot authenticate, which is the legitimate "this repo doesn't use gh" state.

Global config (~/.config/jailbee/global.yaml)

Optional. Host-global settings shared across all repos. It is the required home for the github block (above) and the usual home for the opt-in integration blocks (gpg, ssh, jetbrains, browsers, agents). agents: is valid at both layers, though — see agents above — and a repo entry merges over a global one, so a team default set globally can still be adjusted per repo. Two blocks are unique to this file: the Docker registry mirror overrides, and claude_credentials (below).

docker_registry_mirror:
  enabled: auto                                  # auto | true | false
  port: 3128                                     # rpardini default
  image: rpardini/docker-registry-proxy:0.6.5    # OCI image pin
  data_dir: ~/.local/share/jailbee/registry          # cache + CA storage
Key Default Description
enabled auto auto wires the mirror only into repos that ask for it: a golden image that would contain Docker (golden.stacks.docker, an enable_snippets/install.d 50-docker, a golden.extra_apt_packages entry starting with docker, minus disable_snippets), a non-empty per-repo docker_registry_mirror.extra_registries, or golden.stacks.ecr (which stages a Docker credential helper). true forces it on, false skips all mirror-related work. Both are host-global — this file is host-level, so true set for one undetectable repo also re-imposes the strict-mode jailbee new abort on every other repo on the machine; extra_registries is the per-repo way to opt in. Mirror container lifecycle is unaffected either way (use jailbee registry up/down).
port 3128 Port the rpardini proxy listens on inside the mirror container.
image rpardini/docker-registry-proxy:0.6.5 OCI image podman runs inside the mirror Incus container. Pin to a specific tag — upgrades are deliberate.
data_dir ~/.local/share/jailbee/registry Host directory bind-mounted into the mirror for cache + CA storage.

auto cannot see every route to Docker. A differently-named install.d snippet (55-docker-ce.sh resolves to the logical name docker-ce, not docker), a custom golden.provision_script that installs Docker without golden.stacks.docker being set, and Docker installed by hand inside a running container are all invisible to it. Those repos need enabled: true — or, for the first two, a declared stack and a golden-image rebuild.

When a repo wants the mirror but the mirror container is stopped or missing, jailbee init, jailbee apply and the background egress refresh warn and continue — the ACL simply omits the mirror rule until a later run finds it running. jailbee start / jailbee restart never aborted on this and stay silent: they skip the /etc/hosts mirror pin without comment. jailbee net strict warns, since switching to strict is what removes the container's direct route to Docker Hub. Only jailbee new refuses, and only in strict mode: the default egress allowlist contains no registry hosts, so there the mirror is the container's only route to Docker Hub. In loose mode it is a pull cache, so jailbee new warns and proceeds.

The remedy in every case is jailbee registry up && jailbee apply. Note that apply only re-pins /etc/hosts and re-installs the dockerd proxy on running containers, so a container that was stopped at the time is not fixed by it — start it and run jailbee apply again.

Lifecycle commands: jailbee registry up, jailbee registry down, jailbee registry status (running / stopped / degraded / missing).

claude_credentials

Lets several repos on this host share one Claude Code login. Host-level only, like scratch and config_edit: setting claude_credentials or the computed claude_credentials_dir in a repo's .jailbee/config.yaml is rejected at load time, because a repo config is typically committed and a group name is a property of this one machine, not the team.

claude_credentials:
  group: work                    # default for every repo on this host
  repos:                         # exceptions, keyed by container_prefix
    my-side-project: personal
    solo: null                   # opt this one repo out — keep its own credential
Key Type Default Description
group str \| None None (unset); default in a freshly generated global.yaml Default credential group for every repo on the host. Absent means no sharing.
repos dict[str, str \| None] {} Per-repo override keyed by container_prefix. Wins over group, including when the value is null — that is the only way to keep one repo on its own credential while the rest of the host shares one.

A group name must match [a-z0-9][a-z0-9-]*: it becomes a directory name under <xdg_data_home>/jailbee/claude-credentials/<group>/.

New hosts share by default. jailbee config init --global writes claude_credentials: {group: default} into the generated global.yaml, so every repo on a fresh host shares one login without any configuration: the first /login in any container lands in the group directory, and the next repo is already logged in. The schema default is still None — an existing global.yaml that predates the key keeps every repo on its own credential, and write_global_template refuses to overwrite an existing file without --force. That asymmetry is deliberate: turning sharing on for a host that already has several logged-in repos means answering the two-credential prompt below on every repo but the first, which is a migration, not a default. To opt a whole host out, set group: null.

Only the credential is shared — each repo keeps its own ~/.claude, so project history, MCP config, sessions and onboarding state never cross repos. See Shared credential groups in agents.md for the mechanism.

Joining a group requires jailbee apply: it creates the group directory (mode 0700) and moves this repo's .credentials.json into it.

If both the group directory and this repo already hold a credential, only one of the two logins can be shared and the other becomes unused, so apply asks which to keep:

  • the group's login — this repo's copy is deleted; the repo adopts the account every other member already uses. This is the usual answer.
  • this repo's login — the group's copy is deleted and this repo's is moved in, which re-points every member repo at this account.
  • cancel — nothing changes and apply aborts. To keep this repo on its own login instead, add it under repos: as null (the prompt prints the exact block) and re-run apply.

The losing credential is deleted, not archived. The two are independent grants — two /logins to one account each mint their own refresh-token lineage — so removing one never disturbs the survivor, and a stale credential left in the shared tree is read by nothing. Restoring it means one /login. Without a TTY to ask on (a piped or CI apply), the prompt is skipped and apply refuses instead, changing nothing.

Every successful join leaves this repo's own config home with no .credentials.json of its own — either it had none to begin with, or the move took it. There is no restore-on-leave: leaving a group (remove the key, re-run apply) unmounts the shared directory and the repo's config home is still empty, so the container finds no credential and needs one /login. This is deliberate — moving a credential back on leave would have to guess which of several repos that have been sharing it should get it, and a /login is cheap. jailbee doctor names the group, its directory, and the other member repos.

If the file is absent, defaults apply silently. Invalid YAML → error.

Per-container override: jailbee claude group

Everything above is the repo's permanent group, written to global.yaml and shared by every container of that repo. A single container can also carry a temporary override, stored in its own user.jailbee.claude_group instance label rather than in any file:

  • jailbee claude group use <name>|none [<container>] sets it — <name> moves that one container into another group (creating the group directory if needed), none opts it out of grouping entirely, for as long as the container lives.
  • jailbee claude group reset [<container>] drops the override, so the container falls back to inheriting the repo's group again.
  • jailbee new --claude-group <name>|none applies the override at creation time, in one step.

Precedence: container override beats the repo's claude_credentials entry, which beats the host's group default. A container with no override reads the repo's setting (repos.<container_prefix> if present, else group); a container with an override ignores both.

An override that names the repo's own group is dropped rather than kept. Because the label outranks the repo, one that merely repeats it would look like "follows the repo" right up to the next jailbee claude group set, and then silently keep that one container on the old group. So:

  • jailbee claude group use <the repo's own group> clears the override instead of writing one, and says so;
  • jailbee claude group set/unset clear every override of this repo's containers that the change has made redundant, after the profile has been re-rendered;
  • jailbee new --claude-group <the repo's own group> creates no override at all.

The one exception is claude.enabled: false, where the repo's profile carries no credential device: the label is then the only thing mounting one, so it is not redundant and is left alone. Overrides that already existed are not touched until one of those commands runs — jailbee claude group names them.

jailbee claude group is a command group with no status view of its own: the host-wide picture is jailbee claude ls, per-container labels are jailbee ls's CLAUDE column, and jailbee doctor reports an override that only repeats this repo's group. jailbee claude group ls lists the groups themselves and what each holds, jailbee claude group create <name> creates an empty group, and jailbee claude group rm <name> removes one nothing uses (parking any login it holds rather than deleting it). jailbee claude group set <name>|none and jailbee claude group unset are the permanent, repo-wide equivalents of use/reset — they write global.yaml's claude_credentials.repos.<container_prefix> instead of a container label, so unlike use/reset they affect every container of the repo that has no override of its own.

The override lives on the Incus instance, not in any file under version control: it dies with the container, a recreated same-named container starts without it, and it is not committable and cannot reach a teammate — there is nothing to git add. Restarting Claude in the affected container is still required to pick up the new login either way; none of these commands touch a running session.

jailbee claude use and jailbee claude park take a -g/--group <name> flag that points the command at that group's holder instead of the repo's own. This matters when a group is otherwise unreachable through this repo — e.g. one only a container override uses, with no repo permanently assigned to it via claude_credentials — since without the flag those commands only ever act on the repo's own group.

jailbee claude ls takes -g too, but there it only filters: the listing is host-wide, so every group and the account it holds is already visible without the flag, including a group reachable only as a container override.

A -g command does not touch this repo's own config home. A repo is a member of the holder its claude_credentials entry resolves to, and of no other: its ~/.claude records the account of that group's login, so a command aimed elsewhere neither reads it (it would name the wrong account) nor rewrites it (it would destroy the name of the login this repo does use). The account of a login in such a holder is known from the note JailBee keeps beside a login it activated there itself; one that arrived by /login in a group no repo resolves to has no record to read, and jailbee claude park stores it as unknown-<timestamp> — a working login whose account name is missing, recoverable by activating it, running claude once in a container of that holder, and parking it again.

scratch

Lets jailbee new (and every other command) work in a directory that has no <repo>/.jailbee/config.yaml — a repo you're only poking at for an afternoon, or a folder that isn't a repo config's business at all. Host-level only, like claude_credentials: scratch cannot be set in a repo's own .jailbee/config.yaml — it would be an unknown top-level key there.

scratch:
  enabled: true                      # false -> today's ConfigNotFoundError everywhere
  config:                            # a .jailbee/config.yaml document, same schema
    defaults:
      memory: 8GiB
      network: loose
    golden:
      stacks: {python: true}
Key Type Default Description
enabled bool true Whether a directory with no config file gets one synthesized. false restores the pre-feature behaviour everywhere: ConfigNotFoundError, and the jailbee config init advice.
config map {} A repo-config document, same schema as .jailbee/config.yaml. Validated by the real Config schema at synthesis time — there's no separate, parallel schema for it to drift from.

A synthesized config is layered exactly like a real one, outermost last:

Layer Source Applies to
1 jailbee's built-in field defaults every repo
2 global.yaml's config layer (gpg, ssh, agents.claude, host_mounts, …) every repo, already today
3 global.yamlscratch.config synthesized configs only

Layer 2 is not new here — it is the same set of keys Recommended placement already lists as living in global.yaml, and it already applies to every configured repo on the host. A scratch directory that ignored it would be strictly less useful than the same directory with an empty .jailbee/config.yaml. Layer 3 merges with the same deep_merge() rules as a real repo config — see Merge rules.

Two fields are set by the synthesized layer itself, before scratch.config merges on top:

  • container_prefix<slug>-<digest>. The slug is the directory name lowercased, with runs of non-[a-z0-9] characters collapsed to one - and leading/trailing - trimmed, since a directory name routinely breaks the [a-z0-9][a-z0-9-]* regex (Tutkimus_A, my project) and there is no file to tell the user to edit. The digest is the first six hex characters of sha256 over the directory's resolved path, so ~/a/test-repo and ~/b/test-repo get distinct identities instead of sharing one, and reaching a directory through a symlink does not mint a second one. If the slug comes out empty (nothing but punctuation survives), synthesis fails naming the directory and pointing at jailbee config init, where container_prefix: can be set explicitly.
  • golden.alias — pinned to jailbee-scratch-base, so every scratch directory on the host shares one golden image instead of building its own. Because the alias is shared, the image's content comes only from global.yaml (layers 2 and 3) — no per-directory input reaches it, which is what makes one shared image sound rather than merely convenient.

scratch.config.container_prefix still wins if set, since layer 3 merges normally over the derived default — but setting it makes every scratch directory on the host share that one prefix, hence the same profiles, the same container names, and one registry row between them. Usually you don't want that; it's here for the case you do.

Refused directories. Synthesis refuses the filesystem root (/), $HOME, and any ancestor of $HOME/home, /Users, and so on. All are always a mistaken cd, never a research directory, and the cost of being wrong is a container bind-mounting the user's whole home (or, one level up, everyone's).

Moving a scratch directory changes its identity. The same mechanism seen from the other side: the digest is taken of the path, so mv ~/a/tutkimus ~/b/ derives a new container_prefix, and the containers and profiles created under the old path are left behind. The registry row cleans itself up — the next jailbee apply or jailbee new drops the stale one, and until then the refresh timer logs prefix changed to <new>; run jailbee apply to migrate and skips the repo rather than refreshing it under the wrong name. Everything else has to be removed by hand; see Removing a scratch directory's containers. A repo with a .jailbee/config.yaml is unaffected — its prefix comes from the file (or, left unset, from the directory name), never from the path.

A scratch directory never displaces a configured repo. The derived prefix cannot collide with anything, but scratch.config.container_prefix set host-wide can: if that prefix already belongs to a directory that has a .jailbee/config.yaml, jailbee new and jailbee apply refuse in the scratch directory, naming both paths. Taking the registration over would repoint the refresh timer at the scratch directory and silently re-render the configured repo's egress allowlist from scratch defaults. Run jailbee config init in the scratch directory and set an explicit container_prefix:, or drop the host-wide one.

Removing a scratch directory's containers

A scratch directory owns no config file to delete, and nothing prunes its Incus objects when its container_prefix changes — after a mv, or on the upgrade to 1.5.0, which added the path digest to every synthesized prefix. jailbee dashboard shows the leftovers as their own group, labelled by the old prefix and with no repo behind it; removing them is manual:

OLD=tutkimus                        # the old prefix, as the dashboard shows it

incus list -f csv -c n | grep "^$OLD-"          # what is still there
incus delete -f "$OLD-<branch>"                 # once per container listed
incus profile delete "$OLD-base" "$OLD-binds" \
                     "$OLD-net-strict" "$OLD-net-loose"
incus network acl delete "$OLD" "$OLD-container-extras"
rm -rf ~/.local/share/jailbee/shared/"$OLD"

Delete the containers before the profiles, and not only because Incus refuses to remove a profile still in use: jailbee recognises a container by the <prefix>-base profile it carries, so a container whose profiles are gone disappears from jailbee ls and jailbee dashboard entirely and has to be found with incus list.

The golden image is not among them: every scratch directory shares the one jailbee-scratch-base alias, so it survives any prefix change and must not be deleted alongside.

One command genuinely needs a file. jailbee net egress export prints a replacement for the repo config's egress_allow: key, and there is no such key here to replace; it says so and points at jailbee config init. Promoting a single directory's overrides into global.yaml's scratch.config would widen every other scratch container's allowlist on the host, so it is not offered.

For anything that outlasts an afternoon, jailbee config init is still the answer: a committed, editable, per-repo config beats host-wide scratch defaults shared with every other directory on the machine.

config_edit

Settings for jailbee config edit itself. Host-level only, like claude_credentials and scratch: how your own files get written is a personal editing habit, so a repo's .jailbee/config.yaml cannot set it.

config_edit:
  write_policy: auto      # auto (default) | patch | regenerate
Key Default Description
write_policy auto How a save writes the file. patch touches only the keys you changed and leaves comments, key order and formatting alone. regenerate rewrites the whole file with jailbee's own generated comments — which drops anything you wrote in it by hand. auto picks per layer: regenerate for this file, which jailbee owns and nobody reviews, and patch for a repo config, which is committed and read as a PR diff.

jailbee config edit --write patch|regenerate overrides the key for one run. A regenerate that would drop hand-written comment lines always shows the diff and asks first — that confirmation cannot be turned off.

update_check

Whether JailBee tells you when a newer release is on PyPI. Host-level only, like claude_credentials, scratch and config_edit: whether your machine talks to PyPI is not a repo's decision.

update_check: true      # true (default) | false
Key Default Description
update_check true false stops both the hint and the fetch behind it. JAILBEE_NO_UPDATE_CHECK=1 does the same for a single command, for scripts and CI that cannot edit this file.

The check never runs on a command's path. jailbee ls / new / shell read the last answer out of the state database and, when it is over a day old, start a detached background process that fetches https://pypi.org/pypi/jailbee/json for the next run to read. Nothing identifying you, your host or your repos is sent — the request carries a jailbee/<version> user agent and nothing else — and an offline host simply keeps showing the last answer it had.

When a newer release is known, one line appears on stderr:

⚠ jailbee 1.5.0 is available (you are running 1.4.0).
    Upgrade with: uv tool upgrade jailbee
    Or `jb dismiss update` to stop repeating this.

The upgrade command is derived from how JailBee was installed (uv tool, pipx, or plain pip). An editable install is never advised: it runs from a checkout, so no upgrade command would give you what you want. The same release is mentioned at most once a day, and jailbee dismiss update silences it until a release newer still appears. jailbee doctor reports it either way — including the dismissal — under update check.

--config / -c override

jailbee -c /path/to/config.yaml <subcommand> bypasses discovery and uses the given path. repo_root is derived as the path's grandparent (i.e. the path is assumed to end in .jailbee/config.yaml). The flag is intended for tests and edge cases — odd paths produce odd repo_root values without complaint.