Skip to content

Installation

Prerequisites

  • Linux host (Ubuntu 26.04+ recommended; Wayland session for GUI passthrough)
  • Incus 6.0.5-8 or newer — ships in Ubuntu 26.04's universe repository. That build carries the nested-Docker AppArmor fix (CVE-2025-52881 fallout, backported to the 6.0 LTS series), so nested Docker works out of the box with security.nesting=true — no AppArmor workaround required. The 6.0 LTS series and the current 7.x feature releases both work: jailbee detects the client version and adapts where the CLI changed between them (incus profile assign switched from a comma-joined profile list to separate arguments in 7.3). If the version cannot be read, jailbee assumes the 6.0 LTS syntax. jailbee doctor reports the version it detected.
  • A way to install a Python CLI application — uv or pipx. JailBee itself needs neither at runtime; pick whichever you already have:
    curl -LsSf https://astral.sh/uv/install.sh | sh   # uv
    sudo apt install pipx                             # or pipx
    
  • Host-installed Chrome at /opt/google/chrome and JetBrains Toolbox at ~/.local/share/JetBrains/Toolbox (only needed for the GUI passthrough features)
  • Your project's repo cloned somewhere (e.g. ~/dev/SampleApp)

Quick install

On a fresh Ubuntu 26.04 host with Incus's default networking, these five steps are all you need — they are per-host and one-time. Per-repo setup (building the image, creating containers) then continues in Getting started.

On a default host this is the whole install. If your host runs a firewall or you plan to run many containers at once, the conditional sections further down apply — and jailbee doctor (which Getting started runs first) tells you which.

1. Install Incus and initialise it

sudo apt install incus
sudo adduser $USER incus-admin
# log out and back in
incus admin init --auto

--auto is Incus's non-interactive mode: it takes the defaults the interactive walk proposes — a local storage pool and the incusbr0 bridge JailBee expects — instead of asking a dozen questions you would answer by pressing Enter. It is meant for a host that has not been initialised yet; there is nothing for it to do on one that has. Drop the flag if you want to pick the storage backend (btrfs, lvm, zfs) or a different subnet yourself.

security.nesting=true (needed for nested Docker and for systemd services that use user namespaces) is set automatically by jailbee on every container.

2. Delegate one UID/GID for host-file access

jailbee mounts a few host files (~/.gitconfig, ~/.gnupg, the GPG agent socket, …) into every container as your real host UID via raw.idmap, while the container otherwise runs unprivileged. For that to work, /etc/subuid and /etc/subgid each need two root-delegation lines: the high shifted range Incus uses for the rest of the namespace, plus a single-UID hole for your identity. These commands write both lines idempotently (replace nothing — they read your id -u / id -g):

grep -qxF "root:1000000:1000000000" /etc/subuid \
    || echo "root:1000000:1000000000" | sudo tee -a /etc/subuid
grep -qxF "root:$(id -u):1" /etc/subuid \
    || echo "root:$(id -u):1" | sudo tee -a /etc/subuid

grep -qxF "root:1000000:1000000000" /etc/subgid \
    || echo "root:1000000:1000000000" | sudo tee -a /etc/subgid
grep -qxF "root:$(id -g):1" /etc/subgid \
    || echo "root:$(id -g):1" | sudo tee -a /etc/subgid

sudo systemctl restart incus

A fresh incus admin init usually installs the root:1000000:1000000000 line but never the second one, so this step is required. See Why the UID mapping is needed below for the rationale, the security impact, and a verification recipe.

3. Install JailBee

uv tool install jailbee     # or: pipx install jailbee

This installs the jailbee and jb commands. For the optional Qt dashboard (jailbee gui, and jailbee dashboard --gui), add the gui extra instead:

uv tool install 'jailbee[gui]'      # or: pipx install 'jailbee[gui]'

It is the one install step jailbee setup cannot do for you: the extra goes into jailbee's own environment, so installing it means reinstalling the tool that is running — and only you know whether uv, pipx or a virtualenv put it there. jailbee doctor reports whether it is present, and jailbee gui prints the command when it is not. Adding it later is the same command again.

The authenticated SSH service is a separate optional extra and is never enabled by jailbee setup:

uv tool install 'jailbee[ssh]'      # or: pipx install 'jailbee[ssh]'

Extras can be combined as jailbee[gui,ssh]. Installing the dependency does not install or start a listener; Optional SSH service below is a second, explicit opt-in.

Install git+https://github.com/VRTFinland/jailbee in place of the release when you want the unreleased tip.

JailBee is an ordinary PyPI package and needs neither uv nor pipx at runtime — both are here only because Ubuntu 24.04+ marks its system Python as externally managed (PEP 668), so a bare pip install jailbee is refused. A virtualenv you manage yourself works exactly as well:

python3 -m venv ~/.venvs/jailbee
~/.venvs/jailbee/bin/pip install jailbee
ln -s ~/.venvs/jailbee/bin/jailbee ~/.local/bin/jailbee   # and jb, if you want it

Reaching for pip install --break-system-packages instead is not recommended: it installs into the Python that apt owns.

4. Set up your shell and the refresh timer

jailbee setup

Installing the package puts jailbee and jb on your PATH and nothing else. Three things live outside the package, and jailbee setup is what installs them — interactively, one question per step:

Step What it is Without it
completions Completion scripts for both jailbee and jb, for your shell No TAB completion of commands, container names or branches
timer The jailbee-net-refresh user systemd timer Strict-mode allowlists go stale as the IPs behind GitHub et al. change, and jailbee net loose --for 2h never reverts
skills JailBee's agent skills in each skill-capable agent's own directory (~/.claude/skills, ~/.codex/skills, …) for the agents found on your host. Opt-in — set install_host_skills: true in ~/.config/jailbee/global.yaml first Agents you run on the host itself don't know how to drive jailbee (the containers' skills are installed without this step)

Every step is idempotent, so re-run jailbee setup after upgrading JailBee. --yes installs everything without asking, --only <step> picks one, and --shell bash|zsh|fish overrides shell detection (repeatable). zsh is the one shell that needs a line in ~/.zshrc; jailbee setup offers to add it and prints it for you to copy if you decline.

The timer is a user timer, so it only runs while you have a login session unless linger is enabled — jailbee setup prints the one-liner (sudo loginctl enable-linger $USER) when it is not.

jailbee setup --status reports the three without installing anything, and jailbee doctor reports them afterwards — plus the optional gui extra from step 3 — so a missed one does not stay invisible. If any are still missing the next jailbee ls or jailbee dashboard offers to install them, once.

5. You're done with host setup

Confirm the CLI is on your PATH:

jailbee version

Everything else — creating the golden image and your first container — is per repo. Continue in Getting started. The first thing it has you run, jailbee doctor, flags any remaining host issues, i.e. the conditional sections below.

Optional SSH service

The SSH server is opt-in host tooling for reaching JailBee itself. It is not a host shell, and the default 127.0.0.1:8022 listener is reachable only from this machine. Read Remote SSH before changing the listen address or granting a key.

Install the extra as shown above, add one client public key, then explicitly enable the user service:

uv tool install 'jailbee[ssh]'
jb remote ssh key add
jb remote ssh enable
jb remote ssh status

With no argument, key add prompts and reads one pasted public-key line (for example the contents of ~/.ssh/id_ed25519.pub). A file path also works — jb remote ssh key add ~/.ssh/id_ed25519.pub — and so does piping one in: cat ~/.ssh/id_ed25519.pub | jb remote ssh key add -.

key add prints the full SHA256 fingerprint, algorithm and comment. Use jb remote ssh key ls to copy that fingerprint later and jb remote ssh key rm SHA256:... to revoke it. Keys are read for every new connection, so key changes need no restart. An established connection remains authenticated; close it when revoking access.

Client authorization is stored in ~/.config/jailbee/ssh/authorized_keys; the persistent server identity is ~/.local/share/jailbee/ssh/host_key. Their directories are mode 0700 and the files mode 0600. Enabling or serving creates missing files but never replaces an existing host key. key add accepts one plain OpenSSH public-key line; key options and certificates are not supported.

enable creates private key directories, preserves any existing Ed25519 host key, installs ~/.config/systemd/user/jailbee-ssh.service, and runs systemctl --user enable --now jailbee-ssh.service. The default configuration enables only the dashboard. Configure console or one-shot access under remote.ssh in ~/.config/jailbee/global.yaml.

Inspect the service and its bounded audit log with:

jb remote ssh status
systemctl --user status jailbee-ssh.service
journalctl --user -u jailbee-ssh.service
journalctl --user -u jailbee-ssh.service -f

The unit otherwise follows the login session. To keep it running while this user is logged out, enable systemd linger explicitly (requires root):

sudo loginctl enable-linger "$USER"

After changing listen or port, run jb remote ssh restart; a running listener cannot move itself.

Upgrading JailBee needs no restart: the service compares the installed version with the one it started from on every new connection and every 30 seconds. On a difference it stops accepting connections at once (a client connecting at that moment is told to reconnect), lets the live sessions finish for up to 10 minutes before hanging up the rest, and exits with status 75, which the unit's Restart=on-failure turns into a restart on the new version (background jobs survive, as with any restart). The journal records both versions. A service started by a JailBee from before this check cannot notice an upgrade, and keeps enforcing its old rules while every session runs the new CLI — jb ls, jb new, jb shell and jb remote ssh status say so and name jb remote ssh restart, which this one time is needed by hand. The startup log line host restrictions: on is new with the restricted mode, so its presence shows a current server. Entry-point and command-policy edits apply to a new SSH session without a restart. If access fails, keep the listener on loopback, run jb remote ssh status, validate global.yaml, and inspect the journal. jb remote ssh serve starts the same listener in the foreground for diagnostics, so first stop the unit or choose an unused port; on startup it prints the listening address, the host key's SHA256 fingerprint and a connect example. Running beside the service on another port also accepts one-off overrides for experimentation, without touching global.yaml, e.g. jb remote ssh serve --port 18022 --shell --commands allowlist --allow ls --allow new — see remote.ssh for what each flag overrides and its validation rules. jb remote ssh disable stops and disables the unit but deliberately preserves configuration, authorized keys and the host key for recovery or later re-enablement.


The rest of this page is conditional. Apply a section only if jailbee doctor or the symptom it describes says so; Troubleshooting links here by symptom.

Host networking (only if you use a firewall)

Incus's default networking needs no changes. But if your host runs a firewall, allow the managed bridges used on this host:

  • incusbr0 — Incus's default. Used by the legacy strict profile.
  • jailbee-loose — created by jailbee init. Used by the legacy loose profile so the per-repo allowlist ACL on incusbr0 doesn't leak into "open egress" mode.
  • jailbee-work — created only after the explicit jb net migrate opt-in; used by new work-generation containers in both modes.

firewalld

Add the bridges you use to the trusted zone (jailbee-work only after opt-in):

sudo firewall-cmd --permanent --zone=trusted --add-interface=incusbr0
sudo firewall-cmd --permanent --zone=trusted --add-interface=jailbee-loose
sudo firewall-cmd --permanent --zone=trusted --add-interface=jailbee-work
sudo firewall-cmd --reload

UFW

If your host runs ufw with the default deny (incoming) and deny (routed) policies, containers won't get DHCP leases or reach the internet without opening up the JailBee bridges. jailbee doctor diagnoses this per bridge once at least one container is running on it — network incusbr0 reachability / network jailbee-loose reachability (and network jailbee-work reachability after jailbee net migrate) report which of the three openings below is missing, naming the rule to add. With nothing running on a bridge there is no symptom to read, and the check stays silent (the jailbee-work one says not verified) — so on a fresh host, apply the rules below rather than waiting for doctor to ask for them.

Each active bridge needs the same minimal opening: one ufw route rule plus three before.rules lines, repeated per bridge.

1. Allow forwarding from each active bridge — lets containers reach the internet via NAT. Reply traffic returns automatically through UFW's ESTABLISHED,RELATED rule, so no symmetric "out" rule is needed.

sudo ufw route allow in on incusbr0
sudo ufw route allow in on jailbee-loose
sudo ufw route allow in on jailbee-work

These are persistent (UFW saves them) and visible in ufw status.

2. Edit /etc/ufw/before.rules — UFW ships with a hardcoded silent DROP for UDP 67 (DHCP) and a default-deny for UDP/TCP 53 (DNS) destined to the host, because UFW assumes the host isn't a DHCP/DNS server. Incus's dnsmasq is exactly that on each managed bridge, so we need explicit ACCEPTs that run before ufw-after-input reaches the silent drop.

Add the following lines inside the *filter section of /etc/ufw/before.rules, anywhere before the COMMIT line:

# allow Incus dnsmasq on incusbr0 (DHCP + DNS)
-A ufw-before-input -i incusbr0 -p udp --dport 67 -j ACCEPT
-A ufw-before-input -i incusbr0 -p udp --dport 53 -j ACCEPT
-A ufw-before-input -i incusbr0 -p tcp --dport 53 -j ACCEPT

# allow Incus dnsmasq on jailbee-loose (DHCP + DNS for unrestricted loose bridge)
-A ufw-before-input -i jailbee-loose -p udp --dport 67 -j ACCEPT
-A ufw-before-input -i jailbee-loose -p udp --dport 53 -j ACCEPT
-A ufw-before-input -i jailbee-loose -p tcp --dport 53 -j ACCEPT

# allow Incus dnsmasq on jailbee-work (DHCP + DNS for work-generation bridge)
-A ufw-before-input -i jailbee-work -p udp --dport 67 -j ACCEPT
-A ufw-before-input -i jailbee-work -p udp --dport 53 -j ACCEPT
-A ufw-before-input -i jailbee-work -p tcp --dport 53 -j ACCEPT

Reload UFW: sudo ufw reload.

3. Verify — launch a fresh container on each bridge and confirm it gets an IPv4 address within ~10 seconds:

incus launch images:ubuntu/26.04 ufw-test -c security.nesting=true
sleep 10
incus list ufw-test                       # IPv4 column should be populated
incus delete ufw-test --force

incus launch images:ubuntu/26.04 ufw-test-loose \
    -c security.nesting=true --network=jailbee-loose
sleep 10
incus list ufw-test-loose                 # IPv4 column should be populated
incus delete ufw-test-loose --force

If IPV4 is empty after these steps, run jailbee doctor for diagnostics.

Existing setups. Add rules only for bridges your firewall does not already cover. A legacy-only host does not need jailbee-work; after opting in, add its route rule and three input rules, then ufw reload (or add it to firewalld's trusted zone above). jb doctor probes a running work container and reports the failed DHCP, DNS or allowed-egress stage with the relevant bridge-specific rule. A present bridge with no running work container is reported as not verified, not healthy.

Optional work-network activation and rollback

Package upgrade does not move any container or change the default. Existing containers continue on their legacy bridge and profile. To use the shared, stable-address work network for future containers, first install the one-time firewall rules above when applicable, then explicitly opt in:

jb net migrate

The command prepares the jailbee-work bridge and baseline ACL, then asks before changing the host-wide default for future containers. It cannot prove firewall reachability without a running work container; confirm only after applying host firewall setup. Existing legacy containers are not moved. To undo activation:

jb net migrate --undo

Undo changes only the default for future containers. It does not move existing work containers or remove their bridge/ACLs; those resources remain maintained while any work-generation instance uses them. This migration is optional for existing environments; jb apply maintains both generations and does not silently migrate containers.

Why this is the minimal set. A naive setup might add e.g. ufw allow in on incusbr0 and ufw route allow out on incusbr0 as well, but both broaden the attack surface unnecessarily:

  • ufw allow in on <bridge> would let containers reach any TCP/UDP port on the host (e.g. a host-running database, SSH, etc.). The before.rules entries above expose only the two services Incus's dnsmasq actually serves: DHCP and DNS.
  • ufw route allow out on <bridge> would let the host's LAN initiate inbound connections to containers. We want strictly egress + replies, not ingress, so we omit it. UFW's built-in ESTABLISHED,RELATED ACCEPT in ufw-before-forward already handles reply packets from outbound connections.

The reason the DHCP/DNS rules can't be expressed via ufw allow in commands: UFW puts user rules in ufw-user-input, which runs after ufw-before-input jumps to it. UFW's hardcoded DHCP-suppression DROP lives in ufw-after-input, hit only if nothing earlier accepted the packet. With bridge-netfilter, the user rule's -i <bridge> match sometimes sees the underlying veth instead of the bridge, so the packet falls through to ufw-after-input and gets dropped silently. Putting the ACCEPT into ufw-before-input sidesteps both issues.

Kernel keyring limits (running many containers in parallel)

Running several JailBee containers in parallel exhausts the host kernel's per-user keyring quota long before any disk fills up. The symptom is a misleading error from runc when starting a container or launching nested Docker inside one:

OCI runtime create failed: runc create failed: unable to start container
process: error during container init: unable to join session keyring:
unable to create session key: disk quota exceeded

"Disk quota exceeded" here means kernel keyring quota, not filesystem space. Each Incus container's runc creates a session keyring under the host uid that Incus maps container root to (default 1000000). The per-user default kernel.keys.maxkeys=200 runs out after a handful of concurrent containers — jailbee doctor warns when maxkeys is below its recommended floor of 1000 (the values below set 2000 for headroom).

Raise the limits persistently:

sudo tee /etc/sysctl.d/99-jailbee-keys.conf >/dev/null <<'EOF'
kernel.keys.maxkeys=2000
kernel.keys.maxbytes=2000000
kernel.keys.root_maxkeys=2000
kernel.keys.root_maxbytes=2000000
EOF
sudo sysctl --system
cat /proc/sys/kernel/keys/maxkeys    # should print 2000

To inspect current usage at any time:

cat /proc/key-users                  # column 4 is qnkeys/maxkeys per uid

Why the UID mapping is needed

Step 2 above adds a second delegation line to /etc/subuid and /etc/subgid. After the changes each file must contain (with 1000 replaced by your id -u / id -g; on most single-user systems they're the same):

root:1000000:1000000000
root:1000:1

Verify — launch a fresh container with raw.idmap and confirm it starts:

incus launch images:ubuntu/26.04 idmap-test \
    -c security.nesting=true \
    -c raw.idmap="uid $(id -u) $(id -u)
gid $(id -g) $(id -g)"
sleep 5
incus list idmap-test       # STATUS column should be RUNNING
incus delete idmap-test --force

If the launch fails with newuidmap: uid range [<UID>-<UID+1>) ... not allowed, the delegation lines didn't take effect — check /etc/subuid / /etc/subgid and confirm incus was restarted. jailbee doctor's uid delegation check reads both files and reports exactly which line is missing.

Why this is needed and what it actually means. JailBee containers deliberately combine two things that are usually mutually exclusive:

  • Unprivileged execution — the container's UID 0 is mapped to a high host UID (e.g. 1000000), so a container escape lands on a synthetic user with no real privileges on the host.
  • Sharing a few files with your real host UID — your gitconfig, GPG socket and SSH config must work transparently inside the container, without rewriting ownership.

The mechanism is raw.idmap, which tells Incus: "shift everything to the high range except UID <UID> — leave that one identity-mapped." The kernel side of this is a multi-segment user namespace mapping. The userland side is newuidmap, which refuses to install any segment that isn't authorised in /etc/subuid. The root:1000000:1000000000 line covers the shifted range; the new root:<UID>:1 line covers the carve-out. Both lines together let newuidmap install the full mapping.

Security impact: effectively none. The new line authorises root to delegate exactly one UID — your own. Any process you run already executes as that UID, so this doesn't grant the system any privilege it didn't already have. It only grants Incus the right to project that identity into a container's user namespace, which is precisely what raw.idmap exists to do.