Skip to content

Using JailBee from a Mac

JailBee only runs on Linux, because the Incus daemon is Linux-only (see Why not native macOS). A Mac reaches it in one of three setups:

Setup Where JailBee runs Status
A. A Linux host, the Mac as client A Linux workstation, server or cloud VM you can SSH into Recommended. Windows App against the shared display, clipboard included, is confirmed; the Mac-side recipe below is still being run end to end
B. A Linux VM on the Mac A Colima VM on the same Mac, with the repo shared from macOS Experimental, not verified on real Apple hardware
C. Apple's container machine, one command A Linux machine on the same Mac, set up by jailbee mac setup Experimental. Apple silicon only; the hand recipe it automates was verified on one Mac

Either way, day-to-day use from the Mac goes through the same two things:

  • the SSH service — the dashboard, a JailBee console, one-shot commands and, if enabled, file transfer;
  • the shared RDP display — GUI apps from the containers (an IDE, Chrome, Firefox, your apps: entries), viewed in Windows App (formerly Microsoft Remote Desktop), with a shared clipboard — or, optionally, as native macOS windows through waypipe and the Cocoa-Way compositor.

See Daily use from the Mac for what that looks like, and Limits and workarounds for what it does not do.

Setup A: a Linux host, the Mac as client

The Linux host is set up exactly as in Installation; the steps below add remote access to it. You need an ordinary SSH login to the host (its own sshd) from the Mac.

1. On the Linux host

Install the SSH extra, authorize the Mac's public key (paste the contents of the Mac's ~/.ssh/id_ed25519.pub when asked) and start the service:

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

Native windows over waypipe ssh (step 4) and, under the default commands.mode: full, file transfer work with no configuration. To use the shared RDP display, turn it on in ~/.config/jailbee/global.yaml (see remote.ssh):

remote:
  ssh:
    rdp: true                    # only for the shared RDP display
    files: true                  # sftp/scp into containers; on by default with commands.mode: full
    default_entrypoint: dashboard  # optional: a bare `ssh -t jb` opens the dashboard

Enabling files requires jb remote ssh restart so the service registers file-transfer support. Disabling it blocks new file-transfer channels without a restart, but does not close existing ones. Restart after changing it to apply the setting consistently. rdp and default_entrypoint are reloaded for new requests. Then start the display:

jb display up

The service keeps its default loopback listener (127.0.0.1:8022). The Mac reaches it through the host's own sshd, so nothing new is exposed on the network — see Remote SSH before changing listen.

2. On the Mac: one SSH config entry

Add to ~/.ssh/config, with you@devbox replaced by your normal SSH login to the Linux host:

Host jb
  HostName 127.0.0.1
  Port 8022
  User jailbee
  ProxyJump you@devbox
  HostKeyAlias jailbee-devbox
  LocalForward 127.0.0.1:3389 127.0.0.1:13389
  • ProxyJump connects to the host's sshd first and from there to the JailBee service on the host's loopback.
  • HostKeyAlias keeps the service's host key apart from anything else you reach as 127.0.0.1:8022.
  • LocalForward binds the display tunnel to the Mac's 127.0.0.1:3389 and keeps it open for the lifetime of the SSH connection that created it. Another session may warn bind ... Address already in use; if your first session owns that port, its tunnel still works. Otherwise choose a free local port and use that port in Windows App too. With ExitOnForwardFailure yes, a duplicate forward can prevent the second SSH connection. The destination must be written 127.0.0.1, not localhost (see Remote GUI).

ssh jb help now lists what the service offers.

3. On the Mac: Windows App

Install Windows App (formerly Microsoft Remote Desktop) from the App Store, then:

  1. + → Add PC. PC name: 127.0.0.1:3389.
  2. Leave the credentials at Ask when required. If asked, any user name and password will do — the display checks none (see Security).
  3. Connect while an ssh jb session is open (or keep ssh -N jb running in a terminal tab). Accept the self-signed certificate warning.

You see an empty weston desktop until a container app is launched onto it.

4. On the Mac: native windows (optional)

Instead of the shared display, container apps can open as ordinary macOS windows, one per app window, through a waypipe session and Cocoa-Way, a third-party Wayland compositor for macOS. The windows close when the SSH session ends; keep Windows App for windows that must survive a disconnect.

Tested with Cocoa-Way 2.0.3 and waypipe-darwin 0.11.2 on macOS 26, Apple silicon.

Install both from the Cocoa-Way author's Homebrew tap:

brew tap J-x-Z/tap
brew trust --tap J-x-Z/tap
brew install cocoa-way waypipe-darwin

The tap is a third-party repository, not part of Homebrew's official formulae. brew trust permits Homebrew to load its Ruby code as your user, including updated formulae in later upgrades; it is not a security review or a version pin. Homebrew can also trust individual formulae (brew trust --formula J-x-Z/tap/cocoa-way, and likewise for waypipe-darwin). Building both from reviewed, pinned source avoids the tap but still runs third-party build code.

Start the compositor in rootless mode, so every app window becomes a macOS window of its own:

COCOA_WAY_PRESENTATION=rootless cocoa-way

Without the variable, every app opens inside a single Cocoa-Way window. The variable is read by Cocoa-Way 2.0.3 but not documented upstream, so check it after an upgrade.

Cocoa-Way does not export its socket to your terminal, so a plain waypipe ssh can fail with WAYLAND_DISPLAY is not set. For the default socket location, add this to ~/.zshrc, then open a new terminal or run source ~/.zshrc. It selects the first socket under $TMPDIR/cocoa-way/; use an explicit socket instead if you run several compositor instances or set a custom runtime directory:

cwaypipe() {
  local dir="${TMPDIR:-/tmp}"
  dir="${dir%/}/cocoa-way"
  local sock
  sock=$(find "$dir" -maxdepth 1 -type s -name 'wayland-*' 2>/dev/null | sort | head -n1)
  [ -n "$sock" ] || { echo "Cocoa-Way is not running (no socket in $dir)" >&2; return 1; }
  XDG_RUNTIME_DIR="$dir" WAYLAND_DISPLAY="$(basename "$sock")" \
    waypipe --compress=zstd ssh -o StreamLocalBindUnlink=yes "$@"
}

Then, with the jb entry from step 2:

cwaypipe -t jb dashboard                         # launch apps from the menu
cwaypipe jb chrome <container> --repo PREFIX     # or one app directly

With --repo after the command, no client -- separator is needed. It stays open until the app exits. Window titles carry the full Incus name, for example [PREFIX-feat-x]. The rest of Native windows with waypipe applies unchanged, including -o ControlMaster=no if your ssh config shares connections. Cocoa-Way's repository also ships run_waypipe.sh, which handles additional runtime locations and display selection; the function above is only a minimal default-location helper.

Setup C: Apple's container machine, one command

Experimental. jailbee mac setup automates the recipe in Apple container machine with a custom kernel. The command was run end to end on one Apple silicon Mac (macOS 26.7.1, Apple container 1.6.0), in both modes: it built the kernel, created the machine, created a strict container from a Mac repository, committed in it, pulled the commit back, upgraded, and uninstalled cleanly. Native Mac windows were not checked, because that needs a graphical login; the SSH path they use was. Read the linked page for what each step does and how to do it yourself.

Here JailBee, Incus and the containers run in a Linux machine on the Mac, created with Apple's container. One command builds everything, including the custom Linux kernel that strict network mode needs (Apple's default kernel lacks CONFIG_NF_CONNTRACK_BRIDGE, AppArmor and btrfs).

You need an Apple silicon Mac, Apple's signed container package 1.6.0 or newer (it needs an administrator to install) and uv:

uv tool install 'jailbee[ssh]'
jailbee mac setup

Setup runs unattended for about 15 to 25 minutes (the kernel build is the longest part): it builds the kernel in a throwaway container, builds the machine image, creates the machine, installs Incus with a btrfs pool, installs the same JailBee version inside, builds the base image and finishes by creating and removing a test container. If it stops, it says what to do; run jailbee mac setup again and it continues where it left off.

Option Effect
--isolated Do not share your Mac home with the machine. JailBee and your repos then live in the machine; reach them with ssh jailbee-vm.
--no-gui Skip native Mac windows.
--cpus N, --memory 8G Machine size. Default: half your Mac's cores and memory.
--machine NAME Machine name (default jailbee).
--yes Answer yes to every question, for scripts.

By default the machine shares your Mac home at the same path, so "the host" is your Mac's own repositories:

cd ~/code/your-repo
jailbee new feat/x          # delegated into the machine
jailbee shell feat-x
jailbee firefox feat-x      # a window on the Mac (needs Cocoa-Way, see below)

Native windows use Cocoa-Way and waypipe from a third-party Homebrew tap. Setup asks before installing them, and the windows go through JailBee's SSH service inside the machine, which setup turns on with remote.ssh.restrict_host: false. Only your Mac's key can reach it (through the machine's own sshd), so the dashboard keeps its host commands. Set the key back to true in the machine's ~/.config/jailbee/global.yaml to restrict it. Linux Chrome has no arm64 build; use jailbee firefox.

Freed disk space returns to the Mac: the machine's disk is a sparse image and the root filesystem is mounted with discard.

Upgrade by running jailbee mac setup again. Each layer (the kernel, the machine image, JailBee, the base image) is redone only when its input changed. jailbee mac doctor lists the layers that are out of date.

Remove everything setup created with jailbee mac uninstall. It asks first, because it deletes the machine and everything inside it.

Setup changes these things outside the machine: a machine, an image and a kernel file (~/Library/Application Support/jailbee/kernels); ~/.config/jailbee/mac/ (state and an SSH key); one Include line at the top of ~/.ssh/config; and ~/.config/jailbee/macos.yaml (an existing Colima file is saved as macos.yaml.colima.bak). It never replaces Apple's default kernel without asking.

Setup B: a Linux VM on the Mac (Colima)

Experimental, not verified on real Apple hardware. The caveats in Known rough edges are things to confirm, not solved problems. Corrections welcome.

Here JailBee, the Incus daemon and the containers all run in a Linux VM on the Mac; the git repo stays on the macOS filesystem and is shared into the VM. You type jailbee in the macOS terminal, and the builtin macOS bridge re-runs it inside the VM.

Colima wraps Lima. Its Incus runtime uses an Ubuntu guest image with Incus preinstalled. The recipe below selects Apple's Virtualization.framework backend (vz, requiring macOS 13 or later) and virtiofs for host-folder sharing.

1. Install the tools on macOS

brew install colima incus
uv tool install jailbee   # the macOS bridge

2. Start the VM once

colima start --runtime=incus --vm-type=vz --mount-type=virtiofs \
  --cpu 4 --memory 8 --disk 60

For a new default Colima profile, this starts the Incus VM and shares your macOS home directory read-write through virtiofs. Existing profiles may have custom mounts or storage settings. Confirm that the repo is accessible inside the VM at the same absolute path as on the Mac.

Do not infer working confinement, strict-egress filtering or copy-on-write from the guest OS alone. Check the guest's AppArmor and networking support, and inspect the Incus storage pool before relying on them. Colima's current Incus initialization uses ZFS for a new pool, not btrfs; existing pools may differ. See Storage and the setup B verification checklist.

3. Install JailBee inside the VM once

jailbee mac bootstrap

This installs or upgrades JailBee inside the already-running VM, installing uv there if needed. It installs from JailBee's Git repository, not a pinned copy of the Mac's PyPI release; a version mismatch produces a warning, not an automatic version sync. The bridge uses colima ssh by default; bootstrap does not write or change its transport configuration.

Bootstrap only installs the package. Complete the Linux-side installation and first-time setup inside the VM, including the base image and repository profiles, before creating a configured-repo container. Set the GPG option described below before setup if you do not have a signing agent inside the VM.

4. Use JailBee from any repo under your macOS $HOME

cd ~/code/your-repo
jailbee doctor      # delegated into the VM automatically
jailbee new feat/x
jailbee shell feat-x

Commands are delegated via the bridge, with the Mac's working-directory path passed unchanged to the guest. jailbee mac doctor checks the transport executable, VM reachability, guest JailBee availability, and whether the local working directory is under the configured shared_root. It does not check that the directory is actually mounted in the VM.

For non-Colima transports or custom settings, edit ~/.config/jailbee/macos.yaml with keys: transport, tty_flag, workdir_flag, shared_root. shared_root defaults to the Mac's home directory; changing it neither creates a VM mount nor translates paths. The default transport requests a PTY with tty_flag: ['-t']; if your transport version rejects that flag, adjust it (or set tty_flag: []) and verify interactive commands separately.

5. GUI apps: go through the SSH service

A command delegated by the bridge is not an SSH-service session, so jailbee ide, jailbee chrome and jailbee apps run typed in the macOS terminal try to draw on the VM's own display, which does not exist, and no window appears. GUI apps reach the Mac only when launched from a session of the SSH service running inside the VM:

  1. Do step 1 of setup A inside the VM, for example with colima ssh. JailBee was installed there without the ssh extra, so reinstall it with uv tool install --force 'jailbee[ssh]'.
  2. Add an SSH config entry on the Mac like the one in setup A, with HostKeyAlias jailbee-colima and either:
  3. no ProxyJump at all, relying on Lima forwarding the VM's loopback port 8022 to the Mac's (Lima forwards guest ports by default), or
  4. ProxyJump colima, after adding Colima's own entry with colima ssh-config >> ~/.ssh/config.
  5. Use Windows App as in setup A, and launch GUI apps through ssh jb ... (see Daily use), not through the bridge.

None of these three steps has been verified on a Mac yet: whether the service's user unit runs in the Colima VM (it may need sudo loginctl enable-linger $USER there), and which of the two routes in step 2 works, are open.

GPG signing

The macOS bridge does not forward the Mac's gpg-agent socket into the VM. Unless you arrange a signing agent inside the VM, disable JailBee's automatic GPG integration in .jailbee/config.yaml (see config reference):

gpg:
  enabled: false

This disables the automatic GPG directory/socket mounts, not Git's signing settings or manually configured mounts. If the Git configuration visible in the container enables commit.gpgsign, disable signing separately or provide a working signing setup.

If you do not use GUI apps through either RDP or waypipe, you can also set jetbrains.enabled: false and browsers.chrome.enabled / browsers.firefox.enabled: false to drop those apps from the image.

Known rough edges (setup B)

These are additional checks for the macOS-shared-folder path, not guarantees that the rest of the VM setup is equivalent to a tested Linux host.

  • UID/GID mapping across virtiofs. JailBee maps the configured container UID and GID to the same IDs on the VM host. jailbee doctor compares them with the VM user's IDs and checks subordinate-ID delegation; it does not validate ownership across virtiofs. Test file creation and permissions from both macOS and the container. Do not assume a shift=true device option or a different raw.idmap is a working remedy on your backend.
  • git clone --shared over the share. In the default clone mode, Git borrows objects from the source repository through .git/objects/info/alternates. Reads of borrowed objects cross virtiofs, and the clone depends on that source object store unless dissociated. Check performance and the shared-clone cautions on your setup. jailbee new --mount uses the host repo directly as the working tree; it is not an equivalent isolated clone and is unavailable through the restricted SSH service.
  • Performance and cache placement. Large trees (node_modules, build output) on virtiofs may be slower than a native Linux disk. Keep heavy caches on VM-local storage. JailBee's default shared_dir is the guest's $XDG_DATA_HOME/jailbee/shared/<prefix> (normally under ~/.local/share), but an explicit setting can point elsewhere. Verify the selected path is not on the macOS share; configure a VM-local path if needed.

Alternative VM hosts

Host Notes
Colima --runtime=incus The experimental recipe above; supplies an Incus guest image.
Lima (vz backend) Configure a suitable Linux guest, install Incus, and provide same-path repo mounts. The selected image and kernel need not match Colima's.
Multipass or UTM Candidate Linux VM hosts, not verified JailBee recipes. Check guest kernel facilities, Incus setup, same-path folder sharing, ownership and I/O performance.
Apple container machine Experimental, with a custom kernel: the default kernel lacks bridge connection tracking, AppArmor and btrfs. The tested machine kept repositories on the VM's disk. See Apple container machine with a custom kernel.

These alternatives require their own transport configuration and end-to-end validation; they are not supported substitutes merely because they run Linux.

Daily use from the Mac

With the jb entry from setup A (or B), from a macOS terminal:

To Run
Open the dashboard ssh -t jb dashboard
Open a JailBee console (pick a repo, run commands) ssh -t jb console, or ssh -t jb console --repo PREFIX
Run one command ssh jb ls --repo PREFIX
Get a shell inside a container ssh -t jb shell feat-x --repo PREFIX
Open the IDE or a browser on the shared display ssh jb ide feat-x --repo PREFIX, ssh jb chrome feat-x [url] --repo PREFIX, ssh jb apps run APP --container feat-x --repo PREFIX
Open them as native macOS windows instead (step 4) cwaypipe -t jb dashboard, or cwaypipe jb chrome feat-x --repo PREFIX
Copy files in or out (files, on by default with commands.mode: full) sftp jb, or scp ./notes.md jb:/PREFIX-feat-x/docs/ (the destination directory must exist)
Copy and paste Works between the Mac and the shared display through Windows App

PREFIX is the repo's container_prefix; the console and dashboard let you pick it instead. ssh jb repos lists visible repos and ssh jb help lists allowed commands. One-shot commands without --repo use the only visible repo, or ask with ssh -t when there are several (without a PTY: exit 2 lists them). The old ssh jb -- --repo PREFIX COMMAND form still works; its -- keeps OpenSSH from reading --repo as its own option. When launching onto the shared RDP display with no RDP client connected, the command prints the connection recipe and waits up to two minutes for one. Native waypipe launches do not need an RDP connection.

File transfer lists eligible running clone-mode containers from visible repos by their full Incus names. /PREFIX-feat-x/ maps to that container's repo directory, not its root filesystem. Mount-mode containers are excluded.

With the default restrict_host: true, the remote surface is deliberately narrower than a local terminal: host management commands (apply, config edit, display up, ...) are refused, the git bridge moves refs but never the host's checked-out tree, and a --mount container cannot be entered. Run those on the host itself (setup A) or through the bridge (setup B). The full list is in Remote SSH.

Limits and workarounds

Not available from the Mac Instead
VS Code or JetBrains Remote-SSH through the JailBee service (it routes JailBee commands, not a general-purpose shell/exec environment or arbitrary port forwards) Run the IDE in the container through ssh jb ide feat-x --repo PREFIX, on RDP or via waypipe
Arbitrary TCP forwarding through the JailBee SSH service Use the container's browser, or publish the port to the Linux host and tunnel it through the host's ordinary SSH service (example below)
Telling windows apart by container on the shared display Window titles do not name the container; keep one app per container on screen, or check the address bar / project name. Native windows carry a [<container>] prefix
Audio, GPU acceleration None; the display renders in software
A separate screen per container One shared screen and clipboard for every container (see Security)
GPG signing with a key held on the Mac (e.g. a YubiKey) Not bridged. In setup A, containers sign with the Linux host's gpg-agent as usual
The Qt dashboard (jb gui) Host only; use ssh -t jb dashboard

A dev server in the Mac's browser (setup A)

With the dev server listening on port 3000 inside feat-x, publish it to the Linux host's loopback, then open a tunnel through the host's ordinary sshd:

ssh jb port to-host 3000 feat-x --repo PREFIX
ssh -o ExitOnForwardFailure=yes -N -L 127.0.0.1:3000:127.0.0.1:3000 you@devbox

Keep the second command running and open http://127.0.0.1:3000 on the Mac. This is not a tunnel through ssh jb: that service's TCP forwarding is restricted to the shared RDP display (waypipe uses a separate Unix-socket forward). Both examples bind to loopback; do not switch to 0.0.0.0 unless you intend to expose the service. If port 3000 is occupied on the Linux host, choose another --host-port and adjust the tunnel's destination. If it is occupied on the Mac, change the first 3000 in -L and the browser URL. The Incus forward persists after the SSH tunnel closes; remove the TCP to-host device for container port 3000 when finished:

ssh jb port rm port-th-tcp-3000 feat-x --repo PREFIX

See the command reference for the full port command set.

Why not native macOS

It is tempting to install the native macOS incus client (it exists — brew install incus ships a client-only build) and point it at a daemon running in a VM. This does not work for JailBee, for one decisive reason:

Incus resolves disk device source: paths on the daemon host, not on the client. JailBee mounts many host paths into each container (the repo, shared caches, /etc/localtime, GnuPG, runtime sockets, …). A native macOS client cannot make the Linux daemon bind-mount a macOS path — the path has to exist inside the VM the daemon runs in.

On top of that, JailBee assumes the machine it runs on shares one uid namespace with the containers (raw.idmap, /etc/subuid), reads Linux-only host state (/var/lib/incus, /proc keyring, systemctl --user), and its GUI features assume a Linux display server. All of these hold when JailBee runs on a Linux machine — a separate host or a VM on the Mac — and break when it runs on macOS against a remote daemon. See Architecture for why co-location matters.

Verification

The bridge is unit-tested with a mocked transport, the shared display has been used from Windows App, and native windows through Cocoa-Way have been opened from the dashboard. The end-to-end checks for both setups on real Apple hardware are in Manual testing; they are the acceptance gate before setup B is treated as supported.