Ephemeral Claude Code jails on FreeBSD
Build a “golden” FreeBSD jail with Claude Code pre-installed, snapshot it on ZFS, and zfs clone that snapshot to spin up working sandboxes in seconds. Each clone is independent — destroy and recreate freely without touching the base. Useful when you want:
- Multiple parallel claude instances with separate
~/.claude.json. - Throwaway environments for experimental MCP servers, custom tooling, or anything you’re not sure about.
- Filesystem isolation from your home directory.
- A reproducible “claude environment” you can rebuild from a recipe.
Tested on FreeBSD 15.0-RELEASE amd64 with ZFS root. Should work on 14.x with RELEASE=14.2-RELEASE and graphics/drm-61-kmod in place of drm-latest-kmod (only matters if you also want GPU — see the companion post).
Background: why this isn’t trivial
Claude Code is a Linux ELF, so it needs the Linuxulator. Inside a jail that means:
linprocfs,linsysfs,fdescfs(withlinrdlnk— mandatory),tmpfsfor/dev/shm, all mounted under the jail’s/compat/linux/.linux_base-rl9installed inside the jail (the jail’s own copy, not the host’s).securelevel = -1andallow.chflags(some Linuxulator paths set immutable flags).ip4 = inherit / ip6 = inheritso the jail can hitclaude.ai/install.shwithout VNET plumbing.
And two surprise traps that will silently break things:
- fdescfs needs
linrdlnkor claude hangs forever at startup with no output. (readlink("/dev/fd/N")returnsEINVALwithout it; bun-based apps deadlock when their cwd-resolution fails.) - Don’t single-file
nullfs-mount~/.claude.jsoninto the jail. Atomic-replacerename(2)over a single-file nullfs target wedges the kernel in_vn_lock; even SIGKILL won’t free it. Mount the~/.claudedirectory if you want shared state, and let the jail keep its own~/.claude.json.
The scripts below get both right.
Storage layout
zroot/jails/templates/15.0-RELEASE@base # vanilla base.txz extract
zroot/jails/templates/claude-base@ready # golden snapshot (Claude installed)
zroot/jails/containers/<name> # one ZFS clone per working jail
Everything is copy-on-write. A clone uses ~no extra space until it diverges from the base.
Step 1 — host prep (one-time)
doas pkg install -y emulators/linux_base-rl9
doas sysrc linux_enable=YES jail_enable=YES jail_parallel_start=NO
doas service linux start
The host needs linux_base-rl9 because the jail templates clone the host’s /compat/linux setup pattern. linux_enable ensures the linux64 kernel module loads at boot.
Step 2 — build the golden base (one-time, ~5 min)
RELEASE=15.0-RELEASE
JAIL_USER=$(id -un)
ZPOOL=zroot
# 2a. ZFS layout
doas zfs create -o mountpoint=/usr/local/jails $ZPOOL/jails 2>/dev/null
for ds in media templates containers; do
doas zfs create $ZPOOL/jails/$ds 2>/dev/null || true
done
# 2b. Fetch FreeBSD base userland and extract into a template dataset
doas zfs create -p $ZPOOL/jails/templates/$RELEASE
doas fetch -o /usr/local/jails/media/$RELEASE-base.txz \
https://download.freebsd.org/releases/amd64/amd64/$RELEASE/base.txz
doas tar -xf /usr/local/jails/media/$RELEASE-base.txz \
-C /usr/local/jails/templates/$RELEASE --unlink
doas cp /etc/resolv.conf /etc/localtime /etc/pkg/FreeBSD.conf \
/usr/local/jails/templates/$RELEASE/etc/
doas zfs snapshot $ZPOOL/jails/templates/$RELEASE@base
# 2c. Clone to claude-base
doas zfs clone $ZPOOL/jails/templates/$RELEASE@base \
$ZPOOL/jails/templates/claude-base
doas truncate -s 0 /usr/local/jails/templates/claude-base/etc/rc.conf
Now write a temporary jail config for the build environment:
doas tee /etc/devfs.rules <<'EOF'
[devfsrules_jail_with_bpf=10]
add include $devfsrules_jail
add path 'bpf*' unhide
EOF
doas service devfs restart
doas tee /usr/local/etc/jail.conf.d/claude-base-build.conf <<'EOF'
claude-base-build {
exec.prepare = "
mkdir -p /usr/local/jails/templates/claude-base/compat/linux/dev/fd;
mkdir -p /usr/local/jails/templates/claude-base/compat/linux/dev/shm;
mkdir -p /usr/local/jails/templates/claude-base/compat/linux/proc;
mkdir -p /usr/local/jails/templates/claude-base/compat/linux/sys;
";
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
allow.raw_sockets;
allow.chflags;
exec.clean;
mount.devfs;
devfs_ruleset = 10;
securelevel = -1;
persist;
host.hostname = "claude-base-build";
path = "/usr/local/jails/templates/claude-base";
mount.fstab = "/usr/local/etc/jail.conf.d/claude-base-build.fstab";
ip4 = inherit;
ip6 = inherit;
}
EOF
P=/usr/local/jails/templates/claude-base
doas tee /usr/local/etc/jail.conf.d/claude-base-build.fstab <<EOF
devfs $P/compat/linux/dev devfs rw,late 0 0
linprocfs $P/compat/linux/proc linprocfs rw,late 0 0
linsysfs $P/compat/linux/sys linsysfs rw,late 0 0
fdescfs $P/compat/linux/dev/fd fdescfs rw,linrdlnk,late 0 0
tmpfs $P/compat/linux/dev/shm tmpfs rw,late,mode=1777,size=1g 0 0
EOF
Start it and provision:
doas service jail start claude-base-build
# Bootstrap pkg + install dependencies
doas jexec claude-base-build env ASSUME_ALWAYS_YES=yes pkg bootstrap
doas jexec claude-base-build pkg install -y \
linux_base-rl9 zsh doas ca_root_nss git curl
# Create your user with matching host UID/GID
HOST_UID=$(id -u $JAIL_USER) HOST_GID=$(id -g $JAIL_USER)
doas jexec claude-base-build pw groupadd $JAIL_USER -g $HOST_GID 2>/dev/null
doas jexec claude-base-build pw useradd $JAIL_USER \
-u $HOST_UID -g $HOST_GID -G wheel,video \
-s /usr/local/bin/zsh -m -d /home/$JAIL_USER
# Passwordless doas for wheel
echo 'permit nopass :wheel' | \
doas tee $P/usr/local/etc/doas.conf
# Install Claude as the user
doas jexec -l -U $JAIL_USER claude-base-build /bin/sh -c \
'curl -fsSL https://claude.ai/install.sh | bash'
# Stop the build jail and snapshot
doas service jail stop claude-base-build
doas rm /usr/local/etc/jail.conf.d/claude-base-build.{conf,fstab}
doas zfs snapshot $ZPOOL/jails/templates/claude-base@ready
Step 3 — spawn working jails (seconds, repeatable)
NAME=experiment1
JAIL_USER=$(id -un)
ZPOOL=zroot
P=/usr/local/jails/containers/$NAME
doas zfs clone $ZPOOL/jails/templates/claude-base@ready \
$ZPOOL/jails/containers/$NAME
doas tee /usr/local/etc/jail.conf.d/$NAME.conf <<EOF
$NAME {
exec.prepare = "
mkdir -p $P/compat/linux/dev/fd;
mkdir -p $P/compat/linux/dev/shm;
mkdir -p $P/compat/linux/proc;
mkdir -p $P/compat/linux/sys;
";
exec.start = "/bin/sh /etc/rc";
exec.stop = "/bin/sh /etc/rc.shutdown";
allow.raw_sockets;
allow.chflags;
exec.clean;
mount.devfs;
devfs_ruleset = 10;
securelevel = -1;
persist;
host.hostname = "\${name}";
path = "$P";
mount.fstab = "/usr/local/etc/jail.conf.d/\${name}.fstab";
ip4 = inherit;
ip6 = inherit;
}
EOF
doas tee /usr/local/etc/jail.conf.d/$NAME.fstab <<EOF
devfs $P/compat/linux/dev devfs rw,late 0 0
linprocfs $P/compat/linux/proc linprocfs rw,late 0 0
linsysfs $P/compat/linux/sys linsysfs rw,late 0 0
fdescfs $P/compat/linux/dev/fd fdescfs rw,linrdlnk,late 0 0
tmpfs $P/compat/linux/dev/shm tmpfs rw,late,mode=1777,size=1g 0 0
EOF
doas service jail start $NAME
Launch Claude in a clone
doas jexec -l -U $JAIL_USER $NAME claude --dangerously-skip-permissions
-l sources /etc/profile, -U <user> runs as that user — one process boundary, no su middleman.
For an interactive shell:
doas jexec -l -U $JAIL_USER $NAME zsh
Sharing host state into a clone (optional)
By default each clone is fully isolated. To share specific host directories (settings, source trees), append nullfs lines to the clone’s fstab:
/home/USER/.claude /usr/local/jails/containers/NAME/home/USER/.claude nullfs rw,late 0 0
/home/USER/Work /usr/local/jails/containers/NAME/home/USER/Work nullfs rw,late 0 0
Then doas service jail restart NAME.
Do NOT add a single-file mount for ~/.claude.json. That kernel deadlock is real and SIGKILL won’t help. Either keep ~/.claude.json jail-local, or rsync it in/out around launches.
Tearing down a clone
doas service jail stop $NAME
doas rm /usr/local/etc/jail.conf.d/$NAME.{conf,fstab}
doas zfs destroy zroot/jails/containers/$NAME
Rebuilding the base for a new Claude version
You don’t have to — claude update self-updates inside any clone. But if you want clones to start with the new version baked in:
# Stop and destroy all clones first
doas service jail stop -- $(jls -N name | tail -n +2 | grep -v '^claude-base')
# Then redo step 2 (the snapshot will get destroyed and recreated)
See also
- Running Claude Code on FreeBSD via the Linuxulator — host-only setup, simpler if you only want one instance.
- OpenCL with AMD Radeon GPUs on FreeBSD — host-side setup (drm-kmod, mesa, kld_list, video group). Required reading if you want GPU access.
- Using OpenCL/AMD Radeon GPUs from inside a FreeBSD jail — if your claude jail also needs to run hashcat/whisper.cpp/etc.