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 (with linrdlnk — mandatory), tmpfs for /dev/shm, all mounted under the jail’s /compat/linux/.
  • linux_base-rl9 installed inside the jail (the jail’s own copy, not the host’s).
  • securelevel = -1 and allow.chflags (some Linuxulator paths set immutable flags).
  • ip4 = inherit / ip6 = inherit so the jail can hit claude.ai/install.sh without VNET plumbing.

And two surprise traps that will silently break things:

  1. fdescfs needs linrdlnk or claude hangs forever at startup with no output. (readlink("/dev/fd/N") returns EINVAL without it; bun-based apps deadlock when their cwd-resolution fails.)
  2. Don’t single-file nullfs-mount ~/.claude.json into the jail. Atomic-replace rename(2) over a single-file nullfs target wedges the kernel in _vn_lock; even SIGKILL won’t free it. Mount the ~/.claude directory 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