Skip to content

Apple container machine with a custom kernel

This recipe runs JailBee and Incus inside an Apple container machine, with repositories and caches on the VM's Linux disk. Use the machine's terminal or JailBee's SSH service; SFTP moves files between the Mac and a container's repository.

jailbee mac setup automates everything on this page in one command. Read on for what it does, or to do it by hand.

This is an experimental setup, tested on one Apple silicon Mac on 2026-10-21. It does not establish compatibility with every project. The tested machine was created without the Mac home mounted, by the tester's preference; the recipe does not depend on that, but a machine that shares the Mac home was not tested. See Using JailBee from a Mac for the other arrangements.

The trial ran JailBee 1.7.7. JailBee 1.7.8 fixes three problems the recipe works around: the GID collision, the fonts mount and the display's cloud-init race. Each workaround is marked where it appears; on 1.7.8 it is optional and harmless. From 1.7.8, jailbee doctor also reports the kernel gaps described below: its kernel bridge conntrack and apparmor rows, and kernel btrfs ACLs once a btrfs pool exists. The recipe has not been re-run end to end on 1.7.8.

Tested versions and resources

Component Tested
macOS 26.6.2, arm64
Apple container 1.6.0
Machine 6 CPUs, 12 GiB total VM memory, home mount none
Linux userland Ubuntu 26.04
Incus Ubuntu package 6.0.5-8
JailBee 1.7.7, Python 3.14.4
Kernel Linux 6.18.35, built from Apple's running kernel configuration
Storage btrfs loop pool, 30 GiB
RDP client Windows App 11.4.4

The Apple machine documentation describes its init process and capabilities. No --virtualization option or KVM is needed for Incus system containers and nested Docker. Those are Linux containers inside the machine, not another layer of hardware virtualisation.

Why the default kernel was insufficient

The default kernel used in this test was 6.18.35. An ordinary unprivileged Incus container and nested Docker ran, but a real JailBee strict container failed when Incus installed its per-device nftables rules:

Failed adding bridge filter rules ...
ct state established,related accept
Error: Could not process rule: Protocol error

The running configuration had CONFIG_NF_CONNTRACK_BRIDGE disabled. It also lacked AppArmor and btrfs. The custom kernel enables:

CONFIG_NF_CONNTRACK_BRIDGE=y
CONFIG_SECURITY_APPARMOR=y
CONFIG_BTRFS_FS=y
CONFIG_BTRFS_FS_POSIX_ACL=y

POSIX ACL support is required as well as btrfs itself: without it, copying an instance failed with lsetxattr(..., "system.posix_acl_access") and Operation not supported. A bridge-wide allowlist probe alone did not expose the missing connection-tracking feature; create an actual JailBee container.

1. Build the Linux host image — on the Mac

Install Apple's signed container package from its release page. An older installation may not include the machine plugin. Check the version and start the services, allowing installation of the recommended kernel:

container --version
container system start --enable-kernel-install
container machine --help

Wait for kernel installation to finish before building an image. A running API server alone does not mean the default kernel is configured.

In an empty directory, save this as Dockerfile:

FROM ubuntu:26.04

ENV container=container
ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y --no-install-recommends \
    systemd systemd-sysv dbus dbus-user-session sudo ca-certificates curl \
    openssh-server iproute2 iputils-ping nftables iptables kmod \
    incus incus-client uidmap dnsmasq-base squashfs-tools btrfs-progs \
    attr acl fuse-overlayfs git python3 python3-venv pipx apparmor-utils \
    fonts-dejavu-core \
    && apt-get clean && rm -rf /var/lib/apt/lists/*

RUN truncate -s 0 /etc/machine-id \
    && rm -f /var/lib/dbus/machine-id \
    && systemctl set-default multi-user.target \
    && systemctl mask dev-hugepages.mount sys-fs-fuse-connections.mount \
       systemd-update-utmp.service console-getty.service

The fonts package supplies /usr/share/fonts, which JailBee 1.7.7's base profile mounts even for a headless container. Without that source directory, profile assignment failed before the container could start. (From 1.7.8 JailBee no longer mounts the host's fonts, so the package is optional.)

container build --progress plain --cpus 4 --memory 4G \
  -t local/jailbee-host:trial .
container machine create --name jailbee --cpus 6 --memory 12G \
  --home-mount none local/jailbee-host:trial
container machine run -n jailbee --root -i -t -- bash -l

--home-mount none keeps the Mac home out of the VM, which container machine would otherwise share. That was the tester's choice, not a JailBee requirement; omitting it was not tested.

The builder's 4 GiB and the machine's 12 GiB are separate settings. The following sections explicitly identify the shell in which commands run.

2. Prepare the VM account and build the kernel — as VM root

Set dev_user to the Mac short login name used when the machine was created. The machine creates that account in /home/<name> even with home sharing off.

dev_user=YOUR_MAC_LOGIN
dev_uid=$(id -u "$dev_user")
getent group 1500

Choose an unused GID if 1500 is already occupied. The test Mac's primary GID was 20, which Ubuntu reserves for dialout; JailBee's golden-image provisioning then failed at groupadd -g 20 dev. Give the VM account an unused Linux group. This changes only its VM-local home. (From 1.7.8 the build renames a system group that already holds the GID instead of failing, so this step is optional.)

groupadd --gid 1500 jailbee-dev
usermod --gid jailbee-dev --append --groups incus-admin "$dev_user"
chgrp -R jailbee-dev "/home/$dev_user"
dev_gid=$(id -g "$dev_user")

Complete UID/GID delegation and keyring limits as in Installation:

for mapping in root:1000000:1000000000 "root:$dev_uid:1"; do
  grep -qxF "$mapping" /etc/subuid || printf '%s\n' "$mapping" >> /etc/subuid
done
for mapping in root:1000000:1000000000 "root:$dev_gid:1"; do
  grep -qxF "$mapping" /etc/subgid || printf '%s\n' "$mapping" >> /etc/subgid
done
cat > /etc/sysctl.d/99-jailbee-keys.conf <<'EOF'
kernel.keys.maxkeys=2000
kernel.keys.maxbytes=2000000
kernel.keys.root_maxkeys=2000
kernel.keys.root_maxbytes=2000000
EOF
sysctl -p /etc/sysctl.d/99-jailbee-keys.conf
systemctl restart incus
loginctl enable-linger "$dev_user"

Build the matching Linux source using the running Apple kernel's driver configuration as the starting point. These versions describe the tested combination; revalidate when changing the Apple kernel or Linux version.

apt-get install -y --no-install-recommends build-essential flex bison bc \
  libssl-dev libelf-dev xz-utils cpio pkg-config
curl -fL --retry 2 -o /var/tmp/linux-6.18.35.tar.xz \
  https://cdn.kernel.org/pub/linux/kernel/v6.x/linux-6.18.35.tar.xz
mkdir -p /var/tmp/jailbee-kernel
tar -xJf /var/tmp/linux-6.18.35.tar.xz -C /var/tmp/jailbee-kernel
cd /var/tmp/jailbee-kernel/linux-6.18.35
zcat /proc/config.gz > .config
scripts/config --enable NF_CONNTRACK_BRIDGE \
  --enable SECURITY_APPARMOR --enable BTRFS_FS --enable BTRFS_FS_POSIX_ACL \
  --enable DEBUG_INFO_NONE --disable DEBUG_INFO_DWARF_TOOLCHAIN_DEFAULT \
  --disable DEBUG_INFO_DWARF4 --disable DEBUG_INFO_DWARF5 --disable DEBUG_INFO_BTF \
  --set-str LOCALVERSION -jailbee \
  --set-str LSM 'landlock,lockdown,yama,integrity,apparmor,bpf'
make olddefconfig
make -j6 Image
sha256sum arch/arm64/boot/Image
exit

Keep the checksum to compare with the exported binary. Compilation takes several minutes; disabling debug information avoids the additional debug toolchain and reduces the build size.

3. Select the custom kernel — on the Mac

Use a persistent path, not a temporary directory that may be cleaned later:

jailbee_kernel_dir="$HOME/Library/Application Support/com.apple.container/kernels"
mkdir -p "$jailbee_kernel_dir"
container machine run -n jailbee --root -- \
  cat /var/tmp/jailbee-kernel/linux-6.18.35/arch/arm64/boot/Image \
  > "$jailbee_kernel_dir/vmlinux-6.18.35-jailbee"
shasum -a 256 "$jailbee_kernel_dir/vmlinux-6.18.35-jailbee"

After confirming that the checksums match:

container machine set -n jailbee \
  "kernel=$jailbee_kernel_dir/vmlinux-6.18.35-jailbee"
container machine stop jailbee
container machine run -n jailbee --root -i -t -- bash -l

This selects a kernel for this machine; it does not replace Apple's default kernel. To roll back, stop the machine, run container machine set -n jailbee kernel=, then boot it again. The default kernel still lacks the features this recipe needs.

4. Persist securityfs and initialise Incus — as VM root

uname -r

Expected: 6.18.35-jailbee. AppArmor must be active, not just compiled in. The machine did not mount securityfs automatically. Adding it to /etc/fstab failed at boot in systemd-remount-fs.service; use an early systemd service:

cat > /etc/systemd/system/jailbee-securityfs.service <<'EOF'
[Unit]
Description=Mount securityfs for JailBee's Incus host
DefaultDependencies=no
Before=sysinit.target apparmor.service incus.service
ConditionPathIsMountPoint=!/sys/kernel/security

[Service]
Type=oneshot
ExecStart=/bin/mount -t securityfs securityfs /sys/kernel/security
RemainAfterExit=yes

[Install]
WantedBy=sysinit.target
EOF
systemctl daemon-reload
systemctl enable --now jailbee-securityfs.service
systemctl restart apparmor
systemctl restart incus
cat /sys/kernel/security/lsm
cat /sys/module/apparmor/parameters/enabled

Expected: the LSM list includes apparmor, and its enabled parameter is Y. On a fresh Incus installation:

incus admin init --auto --storage-backend dir
incus storage create jailbee-btrfs btrfs size=30GiB
exit

Inspect an existing installation instead of reinitialising it. The dir pool remains available; use jailbee-btrfs for JailBee environments. See Storage for capacity and copy-on-write behaviour.

Disk space on the Mac

size=30GiB is a ceiling, not an allocation. The pool is a sparse loop file on the machine's disk, which is itself a sparse image file on the Mac, so both grow only as containers write data. Raise the ceiling later with incus storage set jailbee-btrfs size=….

Freed space returns to the Mac only when discards travel down the whole chain. btrfs mounts with discard=async by default, so a deleted file frees the loop file's blocks within seconds. The machine's ext4 root then has to pass the freed blocks on, either continuously or in batches:

mount -o remount,discard /          # continuous, until the next boot
systemctl enable --now fstrim.timer # weekly batch

Run fstrim -av twice to reclaim space at once: the first pass trims btrfs, and the outer ext4 can only trim the blocks that pass released after its journal commits. This chain was measured on 2026-10-22 in a Virtualization framework VM attached the way Apple's containerization attaches disks, not in a container machine: a deleted 512 MiB file left the Mac within 20 seconds with discard on the ext4 mount, and Virtualization framework did punch holes in the APFS image. Whether a container machine root accepts the discard remount has not been tested; to check, compare du -h of the machine's disk image on the Mac before and after deleting a large file.

5. Enter as the development user and create an environment

On the Mac:

container machine run -n jailbee --root -i -t -- runuser -l "$(id -un)"

Use this login form for day-to-day work. A direct container machine run command used only the account's configured primary group in the test; it omitted supplementary incus-admin membership. runuser -l loads the Linux account's updated primary and supplementary groups. Check id in the shell.

Inside the VM, as that user:

pipx install 'jailbee[ssh]>=1.7.8'
pipx ensurepath
export PATH="$HOME/.local/bin:$PATH"
export XDG_RUNTIME_DIR="/run/user/$(id -u)"
export DBUS_SESSION_BUS_ADDRESS="unix:path=$XDG_RUNTIME_DIR/bus"
mkdir -p ~/.config/jailbee
cat > ~/.config/jailbee/global.yaml <<'EOF'
defaults:
  storage_pool: jailbee-btrfs
EOF

Use a VM-local clone of your project, or create a disposable fixture:

mkdir ~/jailbee-smoke
cd ~/jailbee-smoke
git init -b main
git config user.name 'JailBee smoke test'
git config user.email 'jailbee-test@example.invalid'
git config remote.pushDefault origin
mkdir .jailbee
cat > .jailbee/config.yaml <<'EOF'
container_prefix: apple-smoke
golden:
  ubuntu_version: "26.04"
  stacks: {}
gpg:
  enabled: false
egress_allow:
  - example.com:443
EOF
git add .jailbee/config.yaml
git commit -m 'Initial smoke fixture'
jb init
jb setup --only timer --yes
jb base build
jb new smoke main --storage jailbee-btrfs --no-attach --no-autostart
jb exec smoke -- id
jb exec smoke -- git status --short --branch

This fixture deliberately has no project toolchains or agent credentials. Follow project configuration for a real stack. A source repository that exists only under a Mac path such as /Volumes/workspace/... is not available inside a machine created with --home-mount none.

6. Test the boundaries and reboot

From the VM's development-user shell:

jb exec smoke -- curl -fsSI --connect-timeout 3 --max-time 5 https://example.com
jb exec smoke -- curl -sSI --connect-timeout 3 --max-time 5 https://1.1.1.1
curl -fsSI --connect-timeout 3 --max-time 5 https://1.1.1.1

The allowlisted URL must succeed. The unlisted destination must fail inside the container and remain reachable from the VM itself. A timeout alone is not proof of the ACL if that destination is also unreachable from the VM.

Check the AppArmor label from the VM host, using the container's host PID:

instance_pid=$(incus query /1.0/instances/apple-smoke-smoke/state \
  | python3 -c 'import json,sys; print(json.load(sys.stdin)["pid"])')
sudo cat "/proc/$instance_pid/attr/current"
incus config device get apple-smoke-smoke root pool
jb snapshot create smoke verified

The host-side label should include incus-apple-smoke-smoke and (enforce); the pool should be jailbee-btrfs. Inside a nested AppArmor namespace, /proc/self/attr/current can say unconfined while the outer Incus profile is enforced, so the inside view is insufficient evidence.

Stop and boot the machine from the Mac. Once its system bus is ready, run systemctl is-system-running --wait inside the VM. In the test, a command issued immediately after boot sometimes preceded the system bus; retry after boot rather than treating that error as a failed Incus installation.

Repeat the network, host-side label and storage checks, then check:

systemctl --user is-active jailbee-net-refresh.timer
sysctl kernel.keys.maxkeys
jb ls

The timer should be active, the quota at least 1000, and the environment running. Nested Docker was also tested in an unprivileged Incus probe with security.nesting=true, using Ubuntu's docker.io package and docker run --rm hello-world, including after a machine reboot.

Remote commands, SFTP and RDP

Configure key-only access to the VM's ordinary OpenSSH server, then use it as the jump host to JailBee's loopback SSH service. Authorise a public key and start the JailBee service from the VM user shell:

jb remote ssh key add /path/to/mac-public-key.pub
jb remote ssh enable

Use the Mac SSH configuration, with the VM's current IP and development user as ProxyJump. Get the current IP with container machine ls; it changed across reboots in this test. The key must be authorised separately by the VM's ordinary SSH server and by JailBee. Do not expose JailBee's loopback listener just to avoid the jump.

With that SSH alias, the verified command and file paths are:

ssh jb help
ssh jb repos
ssh jb ls --repo apple-smoke
ssh -t jb shell smoke --repo apple-smoke
sftp jb

SFTP lists containers by their full name, for example /apple-smoke-smoke/, and restricts access to their repo directories. A UTF-8 upload/download round trip passed. This transfers files; it does not provide a live Mac bind mount. A diagnostic macOS bridge configuration reached the VM and printed the installed version. That does not establish access to the Mac repository: neither a version command nor jailbee mac doctor verifies that the working directory contains the same files inside the VM. No bridge project workflow was verified in this setup.

For GUI testing, add this to the project's .jailbee/config.yaml:

browsers:
  firefox:
    enabled: true
    source: image

Linux Google Chrome's image installer rejects arm64; the Firefox installer supports it. Add this alongside defaults in the VM user's global.yaml:

remote:
  ssh:
    rdp: true

In the VM's development-user shell, rebuild the image, create a fresh environment, and start a loopback HTTP server for the browser to reach:

jb base build
jb new gui-test main --storage jailbee-btrfs --no-attach --no-autostart
jb exec gui-test -d -- python3 -m http.server 8765 --bind 127.0.0.1
jb display up

The first attempt in this test could not locate weston and waypipe. Cloud-init subsequently reported completion; refreshing package indexes and retrying jb display up succeeded. (From 1.7.8 jb display up waits for cloud-init itself, so this retry should not be needed.)

sudo incus exec jailbee-display -- cloud-init status --wait
sudo incus exec jailbee-display -- apt-get update
jb display up

Follow Remote GUI to tunnel the display to Windows App. The test used local port 33990 to avoid a possible 3389 collision:

ssh -N -o ExitOnForwardFailure=yes \
  -L 127.0.0.1:33990:127.0.0.1:13389 jb

Connect Windows App to 127.0.0.1:33990. Launch Firefox through the JailBee SSH service, not through a plain machine command:

ssh jb firefox gui-test http://127.0.0.1:8765 --repo apple-smoke

The URL reaches the HTTP server inside that environment. Windows App showed the Weston desktop, and Firefox requested a page from the container's own loopback successfully in the test.

The creation command emitted a logind runtime-directory timeout warning for the headless environment. The subsequent explicit SSH/RDP Firefox launch succeeded. Do not treat a background launch message alone as a working GUI: verify the visible window, typing, and clipboard in both directions.

Native Mac windows with Cocoa-Way

The same machine also accepted a waypipe connection from Cocoa-Way 2.1.1 with waypipe-darwin 0.11.2 on the Mac; the display container had waypipe 0.11.0. Firefox loaded its container-local HTTP page, and the compositor logged creation of a rootless Mac window and presentation of its first frame. The test form received the expected pasted and typed tokens. The reverse-direction token also matched the Mac clipboard, confirming text clipboard transfer in both directions in this trial.

Install Cocoa-Way and waypipe-darwin and define cwaypipe using the Mac native-window instructions. Start the compositor on the Mac:

COCOA_WAY_PRESENTATION=rootless cocoa-way

In another Mac terminal, with the same SSH alias and the test HTTP server still running in gui-test:

cwaypipe -o ControlPath=none jb firefox gui-test \
  http://127.0.0.1:8765 --repo apple-smoke --move

--move closes this test browser on its previous display and opens it on Cocoa-Way. The SSH command stays attached; ending it closes the window. JailBee runs the server-side waypipe in jailbee-display, so installing it in the VM host or the project container is unnecessary. This path uses the existing SSH jump host and does not require a Mac home mount or RDP client.

Verify the visible window, typing and clipboard in each direction before using it for daily work. cocoa-wayctl --json clipboard reports transfer metadata without printing clipboard contents.

Finnish keyboard layout in Cocoa-Way 2.1.1

The first manual trial passed ASCII typing and text clipboard transfer but used a US keyboard layout despite Finnish being selected in macOS. Cocoa-Way 2.1.1 constructs an explicit US XKB layout, so changing the Mac input source or setting XKB_DEFAULT_LAYOUT does not replace that explicit setting.

For a temporary Finnish Mac trial, use a private copy of the XKB data. This workaround changes the compositor's interpretation of us only in that copy; it does not modify the installed data or macOS keyboard settings:

cocoa_xkb=$(mktemp -d /tmp/jailbee-xkb.XXXXXX)
cp -R /opt/homebrew/share/X11/xkb/. "$cocoa_xkb/"
cat > "$cocoa_xkb/symbols/us" <<'EOF'
default partial alphanumeric_keys
xkb_symbols "basic" {
    include "fi(mac)"
    include "level3(alt_switch)"
};
EOF
COCOA_WAY_PRESENTATION=rootless XKB_CONFIG_ROOT="$cocoa_xkb" cocoa-way

Close the previous test compositor before launching this one, then reconnect the waypipe browser. The Finnish Mac keymap compiled with the expected ä, ö, å, Ä, Option-8 [ and Shift-Option-8 { symbols, and keyboard input was confirmed manually with this override. Exhaustive coverage of every shortcut and input source was not tested. This is a fixed XKB Finnish Macintosh layout, not automatic synchronisation with arbitrary macOS input sources. Quit this compositor and start without XKB_CONFIG_ROOT to return to the installed behaviour.

Scope of verification

Path Result
Golden image, branch clone, mapped user, git bridge Passed
Strict per-container egress, positive and negative destinations Passed
Host-side AppArmor enforcement after reboot Passed
btrfs instance migration and snapshots, retained after reboot Passed
jailbee mac setup end to end, both modes, upgrade and uninstall (2026-10-22, 1.6.0) Passed on one Mac, with the machine's own container machine root measured: fstrim and a discard mount both shrink the Mac's disk image
Native Mac windows through jailbee mac setup Not checked: needs a graphical login
Freed btrfs space returned to the Mac Measured in a separate Virtualization framework VM, not in a container machine
Nested Docker hello-world after reboot Passed
User refresh timer and keyring limits after reboot Passed
SSH repository discovery, listing and interactive shell Passed
Restricted SFTP UTF-8 round trip Passed
Windows App desktop Visually confirmed
Firefox page request through SSH/RDP Passed, including typed and pasted input
Clipboard Mac → container Passed
Clipboard container → Mac Failed in the initial manual test; menu-copy retest pending
Native macOS bridge Transport/version probe only; no project workflow verified
Live Mac repo mounts Not tested: the test machine did not share the Mac home
Cocoa-Way/waypipe Firefox Page, rootless window and ASCII input passed; default layout is US
Finnish Mac keyboard workaround XKB symbols verified; keyboard operation confirmed manually
Cocoa-Way text clipboard, both directions Passed: form token and Mac clipboard token verified
JetBrains and full application stacks Not tested

The normal Apple machine runner did not initialise supplementary groups; the login command above avoids that issue. When automating checks, save a script inside the VM and execute that file. Streaming a script into bash -s and running incus exec inside it let the child consume the remaining script from stdin, silently skipping later checks.