Running Claude Code on FreeBSD via linuxulator

Anthropic now ships Claude Code as a native Linux binary only — the older self-compilable Node distribution is gone. FreeBSD can't run that ELF directly, so the working pattern is linuxulator + Ubuntu jammy + a wrapper script that chroots into it. The whole thing takes maybe ten minutes if your linuxulator is already set up. This article documents the steps that worked on my home FreeBSD box, and the IPv6 routing gotcha that bit me on one machine but not the other.

The setup, end to end

You need:

  • A working linuxulator with Ubuntu jammy at /compat/ubuntu. /compat/linux should be a symlink to /compat/ubuntu.
  • security.bsd.unprivileged_chroot=1, so the wrapper script can chroot as a regular user.
  • A user that exists in both FreeBSD's /etc/passwd and /compat/ubuntu/etc/passwd, with a shell that's actually present inside the chroot.

The pattern is: install Claude Code as your user inside the chroot, then call it from FreeBSD via a thin wrapper that runs chroot /compat/ubuntu claude "$@".

Prerequisites

I won't re-document linuxulator setup here — the FreeBSD handbook covers it well. Confirm yours has Ubuntu jammy at /compat/ubuntu and that /compat/linux -> /compat/ubuntu is a symlink. The compat.linux.emul_path sysctl will tell you what's active.

Enable unprivileged chroot:

sysctl security.bsd.unprivileged_chroot=1

Persist in /etc/sysctl.conf:

security.bsd.unprivileged_chroot=1

Without this, a non-root invocation of chroot(2) returns EPERM and the wrapper fails for everyone except root.

Step 1 — verify the chroot works as root

chroot /compat/ubuntu /bin/bash

Inside, confirm apt is alive:

apt update

If apt can't reach repositories, fix that before moving on. Common culprits: /compat/ubuntu/etc/resolv.conf doesn't exist or points at a stale DNS, or your host's network is broken (see the sidebar at the end).

Step 2 — verify your user works inside the chroot

Still chrooted as root:

su - jtmack    # or whatever your username is

You should land in a working user shell.

Shell gotcha. My FreeBSD login shell is fish, and fish isn't installed inside /compat/ubuntu. The shell field in /compat/ubuntu/etc/passwd for your user must point at a shell that exists inside the chroot. I left mine at /bin/bash and it just works:

jtmack:x:1001:1001:Jim Mack:/home/jtmack:/bin/bash

If su - <user> fails with a no-such-shell message, that's your problem — edit /compat/ubuntu/etc/passwd.

Step 3 — install Claude Code inside the chrooted user shell

From the user shell (still inside the chroot):

curl -fsSL https://claude.ai/install.sh | bash

The installer ran clean for me with no errors. It drops the claude binary into the user's local install path — typically something under ~/.local/bin or the equivalent. Whatever the installer prints at the end, that's where the binary lives inside the chroot. You don't need to add it to your FreeBSD $PATH because the wrapper invokes the chrooted binary directly.

Step 4 — wrapper script on the FreeBSD host

/usr/local/bin/linux_claude:

#!/bin/sh

chroot_path=/compat/ubuntu

# Execute inside the chroot, disable sandbox
/usr/sbin/chroot -n "$chroot_path" claude "$@"
chmod +x /usr/local/bin/linux_claude

That's the whole thing. Arguments forward through "$@", the chroot is opaque to the caller, and Claude Code's stdio streams back to your FreeBSD terminal as if it were a native command.

If you want to call it as just claude, alias it in your shell:

# ~/.config/fish/config.fish
alias claude='linux_claude'

Test:

linux_claude

You should land in Claude Code's interactive shell. Done.

I hit this on one of my two FreeBSD boxes. Same install method, same network, but on the second box claude started up and immediately died with:

Unable to connect to Anthropic services
Failed to connect to platform.claude.com: ECONNREFUSED

ECONNREFUSED from a Node.js process under linuxulator can be misleading. In native Linux it means the destination sent a TCP RST. Under linuxulator it can also mean "the kernel returned an unreachable-family errno on the IPv6 leg, the linux compat layer translated it imperfectly, and Node surfaced it as ECONNREFUSED instead of EHOSTUNREACH." Same root cause, different symptom.

The actual problem on my box: no IPv6 default route. netstat -rn -f inet6 showed the SLAAC-assigned /64 from Comcast on re0, but no default line. Node's Happy Eyeballs raced an A and AAAA in parallel; the AAAA leg failed; the failure surfaced as ECONNREFUSED before the A leg could complete.

The cause was a non-obvious rc.conf mistake: bridge0 carried an IP on the same subnet as the physical NIC re0 (which is itself a bridge member). Two L3 interfaces on the same segment, one of them with accept_rtadv and the other without — the kernel attributed the inbound Router Advertisements to the wrong interface, and the v6 default route never installed. My other FreeBSD box, with no IP on the bridge, was fine.

The fix:

cloned_interfaces="bridge0"
ifconfig_bridge0="addm re0 up"           # no inet — L2 only
ifconfig_re0="inet 192.168.1.16/24"
defaultrouter="192.168.1.1"
ifconfig_re0_ipv6="inet6 accept_rtadv"
rtsold_enable="YES"
# no ipv6_defaultrouter — let RA install it

Plus tracking down a stale 2601:645:4680::36/128 static address in a jail config (Comcast had rotated my v6 prefix some time back; the old one was still hardcoded).

If you hit the same ECONNREFUSED, the fast triage:

netstat -rn -f inet6 | grep -E 'default|::/0'
fetch -4 -o /dev/null https://platform.claude.com/
fetch -6 -o /dev/null https://platform.claude.com/

If fetch -4 works and fetch -6 doesn't, you've found it. Either fix v6, or as a quick unblock, force Node to prefer IPv4:

set -x NODE_OPTIONS "--dns-result-order=ipv4first"

The real fix is restoring v6 connectivity, but the env var gets you working immediately while you do that.

What's actually happening when you run it

Worth stating plainly so the surface area isn't mysterious:

  • The claude binary is a Linux ELF, executing inside the linuxulator. Its syscalls get translated by the FreeBSD kernel.
  • Networking uses the FreeBSD host's stack — pf rules, routes, IPv6 default route, DNS resolution paths, all FreeBSD-side. There is no separate network namespace.
  • Filesystem access is rooted at /compat/ubuntu. When Claude Code reads or writes ~/.claude or its config, those paths land inside the chroot. If you want Claude Code to see your real project directories, mount them in (mount_nullfs /home/jtmack /compat/ubuntu/home/jtmack or via /etc/fstab). I haven't done this yet — I work with one project at a time and copy state in.

Status / things I'd still like

  • An fstab line that null-mounts /home/jtmack into the chroot, so Claude Code sees the same files I do from FreeBSD without any copy step.
  • A way to run Claude Code's MCP servers from FreeBSD-side processes that don't themselves run inside the chroot — I haven't poked at this yet.
  • The wrapper currently runs as root via chroot(8)'s privileged path on systems that don't have unprivileged_chroot=1. The sysctl is the right answer for a single-user box; it'd want a rethink on a multi-user system.

This article was drafted by Claude (Anthropic's Claude Code, Opus 4.7) from my Obsidian notes under Personal/FreeBSD/FreeBSD-HomeBox/ — specifically Installing Claude Code on FreeBSD.md and IPv6 Default Route Lost After Bridge Misconfiguration.md — plus the live debugging conversation that produced both. The setup, the gotcha, and the fix are mine; the prose stitching is Claude's.