bsdkrun
A Firecracker-style microVM launcher for BSD, Linux, and unikernel guests on macOS and Linux, built on libkrun (which drives Apple's Hypervisor.framework on macOS and KVM on Linux).
bsdkrun is a thin, purpose-built CLI: it wraps libkrun's C ABI in a handful of safe Rust
bindings and boots a guest three ways — from a UEFI firmware image (the guest's own EFI loader
boots a normal disk), from a direct kernel + FDT (no bootloader), or straight from an OCI
image (bsdkrun linux alpine pulls it from any registry, extracts the rootfs, and boots it like
docker run). It also boots unikernels — Unikraft,
Nanos, OSv, and
MirageOS/Solo5 — and bsdkrun pack turns an
ordinary project into a bootable Unikraft unikernel. It is deliberately small: one FFI module,
one CLI, no daemon required.
Important
Platforms: macOS on Apple Silicon (Hypervisor.framework) and Linux on amd64 or arm64 (KVM). A hardware-virtualized guest runs the host's CPU arch, so bsdkrun detects the arch and pulls the matching kernel, OCI image, and agent automatically. macOS is arm64-only; Linux works on both x86_64 and aarch64. FreeBSD boots via EFI on macOS and via PVH direct kernel on Linux/amd64; NetBSD direct-boots its kernel everywhere. The amd64 PVH boots need our PVH-enabled libkrun fork. (Linux support is new — see the KVM e2e CI.)
Contents
- Features
- Why this exists
- Install
- Prerequisites
- Build
- Usage
probe-
freebsd/netbsd firmwarekernellinuxunikraftnanos-
solo5/mirage
- Managing machines
- Flavors — preconfigured environments & snapshots
- Networking
- Machine domains — local DNS + HTTPS
- TUI — the terminal dashboard
- Disks
- Console
- Preparing a guest image
- SDKs
- Project layout
- Troubleshooting
- Status
- License
Features
Guests & boot paths
-
One-liner BSD microVMs —
bsdkrun freebsd/bsdkrun netbsdfetch a bundled image (guest agent baked in) and boot it: run a command à ladocker run(bsdkrun freebsd -- uname -a), or get dropped straight into an interactive shell. -
Any OCI image as a Linux microVM —
bsdkrun linux alpinepulls from any registry (no Docker daemon), extracts the rootfs, and boots itdocker run-style;bsdkrun systemdflips a debian/ubuntu/fedora guest into a full systemd system. -
Unikernels — Unikraft,
Nanos, OSv, and
MirageOS/Solo5, each with its own subcommand;
bsdkrun packturns an ordinary project into a bootable Unikraft unikernel. - Three boot protocols — UEFI firmware (the guest's own EFI loader), direct kernel + FDT (no bootloader), and PVH direct kernel on Linux/amd64 (via the PVH libkrun fork).
- macOS and Linux hosts — Hypervisor.framework on Apple Silicon, KVM on Linux (amd64/arm64), behind one CLI.
Machine management
-
Docker-style lifecycle — short ids,
ps/logs -f/exec/shell/stop/start/update/rmacross every guest type; state in a small SQLite database, no daemon required. -
In-guest exec agent —
exec/shellrun real processes in Linux and BSD guests over a small framed TCP protocol (PTY, env vars, exit codes) — no vsock dependency. -
Copy-on-write everything — per-machine APFS/reflink clones boot many
microVMs from one base image instantly; named persistent volumes (
-v), host bind mounts (--mount), extra disks (--attach-disk), andgrowto enlarge images. -
Flavors & snapshots — ready-to-boot environments
(languages, databases, web servers, AI coding agents), your own
flavors.tomlstacks, andcommitto freeze a running machine into a reusable flavor, likedocker commit. -
Clone a repo on boot —
--repo <git-url>clones into the guest (installing git if the base lacks it) and your shell lands in the checkout.
Networking
-
Internet by default — every guest gets a virtio-net NIC behind
gvproxy (userspace NAT with DHCP and DNS);
--port HOST:GUESTforwards ports, and a unique host port is forwarded to the guest's SSH automatically. - Global networks — shared subnets with internal DNS so machines reach each other by IP and by name, docker-compose style.
-
Machine domains —
bsdkrun domains enablegives every machine a browser-trustedhttps://<name>.bsdkURL (built-in DNS responder + Caddy's local CA). -
SSH & Tailscale in one command —
bsdkrun ssh <id> setupinstalls your keys and sshd;bsdkrun tailscale <id> setupputs the guest on your tailnet.
Interfaces & tooling
- TUI dashboard — live panels for machines, images, volumes and networks, with fuzzy search, a new-machine wizard, and a follow-mode log viewer.
- Desktop app — machines list with a tabbed terminal panel (the screenshot up top).
- SDKs for eight languages — TypeScript, Python, Ruby, Elixir, Gleam, Clojure, Go, and Rust: thin, stateless wrappers around the binary.
-
Agent skill — the full CLI reference packaged for coding agents
(
npx skills add tsirysndr/bsdkrun). - Install anywhere — Homebrew, a curl one-liner, npm, or a Nix flake; the macOS binaries ship pre-signed with the hypervisor entitlement.
Why this exists
The usual microVM stacks (Firecracker, Cloud Hypervisor) don't run on macOS, and the usual macOS
VM tooling (QEMU, vftool, UTM) isn't microVM-shaped. libkrun gives you a Firecracker-like
"configure a context, then start_enter" model on top of Hypervisor.framework and KVM — but its
batteries are aimed at Linux guests. bsdkrun leans into that (running any OCI image
docker run-style, no Docker daemon) and then points the same machinery at everything libkrun was
never aimed at:
-
BSD guests as first-class microVMs. FreeBSD and NetBSD boot to multi-user on macOS and
Linux — via EFI firmware, direct kernel, or PVH direct kernel (through our
PVH-enabled libkrun fork) — and get
the same
exec/shell, volumes, and networking a Linux guest gets. This started as a research question — can a BSD kernel enumerate the virtio-mmio devices libkrun describes, and find its console? — and the answer turned out to be yes; the pieces that made it true (PVH entry, an MPTable, serial-console wiring) now live in the fork and in bsdkrun itself (see Status). -
One CLI for every unikernel. Unikraft, Nanos, OSv, and MirageOS/Solo5 each ship their own
runner with its own flags and quirks. bsdkrun boots them all with the same Docker-style
lifecycle (
ps/logs/stop/rm), andbsdkrun packgoes further: point it at an ordinary project and it builds a bootable Unikraft unikernel — no unikernel expertise required. -
Docker ergonomics without a daemon. Short ids, copy-on-write clones, named volumes,
networks with internal DNS,
https://<name>.bsdkdomains, flavors andcommit— for every guest type above, from one small CLI backed by a SQLite file.
Install
macOS (Apple Silicon) — a prebuilt, already-signed binary via Homebrew:
brew install tsirysndr/tap/bsdkrunThis pulls in tsirysndr/tap/libkrun — our libkrun fork, which carries the PVH boot support and
the virtio-fs fixes bsdkrun's guests need — plus gvproxy from libkrun/krun, auto-tapping both.
The binary ships codesigned with the hypervisor entitlement, so there's nothing else to set up —
jump to Usage.
If you already have upstream libkrun/krun/libkrun, the two conflict (same install paths) and
Homebrew will ask you to remove it first:
brew uninstall --ignore-dependencies libkruncurl — install the prebuilt host binary for your platform (macOS/arm64, Linux/x64, Linux/arm64) with a one-liner:
curl -fsSL https://raw.githubusercontent.com/tsirysndr/bsdkrun/main/install.sh | shThe script downloads the matching release archives, verifies their SHA-256s, and unpacks
everything into ~/.bsdkrun/bin (override with BSDKRUN_INSTALL): the bsdkrun CLI, the
bsdkrund daemon, and bsdkrun-supervisor (which the daemon finds beside itself) —
one directory because the Linux CLI archive bundles libkrun beside the binary. It also fetches
gvproxy for guest networking (skip with BSDKRUN_SKIP_GVPROXY=1), and on macOS installs our
libkrun fork via Homebrew when brew is available. Pin a release with BSDKRUN_VERSION:
curl -fsSL https://raw.githubusercontent.com/tsirysndr/bsdkrun/main/install.sh | BSDKRUN_VERSION=v0.8.1 shnpm — install the prebuilt host binary for your platform (macOS/arm64, Linux/x64, Linux/arm64):
npm install -g @bsdkrun/cli # or: npx @bsdkrun/cli linux alpine -- echo hiA postinstall step downloads the matching bsdkrun from the GitHub release and verifies its
SHA-256. On Linux the archive bundles libkrun (libkrun.so/libkrunfw.so, rpath'd to
$ORIGIN), so it works with no separate libkrun install — only gvproxy is needed for guest
networking. On macOS it's just the binary and links Homebrew's libkrun
(brew install tsirysndr/tap/libkrun).
Unsupported platforms (Windows, Intel macOS, 32-bit) fail the install with a clear message. See
npm/ for details.
Nix flake — builds bsdkrun with all its dependencies. On Linux (amd64/arm64) it links
nixpkgs' libkrun; on macOS it links your Homebrew libkrun, so those need --impure
(brew install tsirysndr/tap/libkrun first) and produce a binary re-signed with the hypervisor
entitlement.
# Linux — needs /dev/kvm access: sudo usermod -aG kvm $USER && newgrp kvm
nix run github:tsirysndr/bsdkrun -- linux alpine # run without installing
nix profile install github:tsirysndr/bsdkrun # install into your profile
nix develop github:tsirysndr/bsdkrun # dev shell with the full toolchain
# macOS (Apple Silicon) — impure link against Homebrew's libkrun
brew install tsirysndr/tap/libkrun
nix build --impure github:tsirysndr/bsdkrun # -> ./result/bin/bsdkrun
nix run --impure github:tsirysndr/bsdkrun -- linux alpineThe flake wraps the runtime tools (curl, tar, gzip, xz, cpio, gvproxy, …) onto PATH,
and nix develop adds the Rust toolchain plus zig/cargo-zigbuild for cross-building the guest
agents. The Solo5 tender is built from a pinned flake input, so a nix build needs no
?submodules=1 — nix build .#solo5-hvt builds just the tender if that is all you want. To hack on bsdkrun without Nix, build from source — see Prerequisites and
Build.
Prerequisites
You need libkrun, a Rust toolchain (rustup default stable; edition 2021), and access to
the hypervisor. The hypervisor part differs by OS.
Optionally, a Go toolchain (go 1.22+, matching pack/go.mod) if you want
bsdkrun pack in your build: core/build.rs compiles pack/ and embeds it into the
bsdkrun binary. Without Go, the build still succeeds — it just warns and ships without pack
support (see core/build.rs::ensure_pack_binary). This is a source-build-only requirement: the
released binaries (Homebrew, npm) already have it embedded, so installing bsdkrun that way never
needs Go on your machine.
macOS (Apple Silicon)
bsdkrun links our libkrun fork from the tsirysndr/tap tap. krunkit comes from the
libkrun/krun tap (redirected from the old slp/krun). Homebrew 6.x requires you to trust a
third-party tap before it will run its install code:
brew tap tsirysndr/tap
brew tap libkrun/krun
brew trust libkrun/krun # required on Homebrew 6.x for third-party taps
brew install tsirysndr/tap/libkrun krunkit-
libkrunprovideslibkrun.dylib(the C ABI we link against). -
krunkitships the EDK2 UEFI firmware we use for EFI boot (.../share/krunkit/KRUN_EFI.silent.fd).
Why the fork and not upstream libkrun/krun/libkrun? It carries two changes bsdkrun depends
on. PVH direct boot, which is how FreeBSD and NetBSD boot on Linux/amd64. And XATTR_NOFOLLOW in
the virtio-fs xattr handlers — upstream follows the final symlink, and since a nix profile is
built entirely from symlinks whose absolute targets only exist inside the guest, the host
resolves them against its own root and returns ENOENT. nix then aborts with querying extended attributes of "…": No such file or directory on a path that ls happily shows. Upstream libkrun
still works for most guests; nix guests need the fork.
The two formulae install to the same paths and therefore conflict, so remove upstream's first if
you have it (brew uninstall --ignore-dependencies libkrun). They share an opt prefix — it is
keyed on the formula name, not the tap — so an already-linked bsdkrun keeps resolving across the
switch.
The Hypervisor entitlement (the part that bites everyone). A binary that calls libkrun must be
codesigned with com.apple.security.hypervisor (plus com.apple.security.cs.disable-library-validation
so it can load the Homebrew dylibs). Without it, krun_create_ctx/krun_set_vm_config succeed but
krun_start_enter fails at VM creation with Internal(Vm(VmSetup(VmCreate))) / errno 22
(EINVAL). Worse, every cargo build strips the codesignature, so you must re-sign after each
build — the Makefile does this for you (entitlements in
bsdkrun.entitlements).
Linux (amd64 or arm64, KVM)
libkrun uses KVM on Linux — no codesigning, but you need /dev/kvm access. Add yourself to
the kvm group (or run under sudo):
sudo usermod -aG kvm $USER && newgrp kvmUbuntu has no libkrun package, so build it (and its bundled kernel, libkrunfw) from source — see the KVM e2e workflow for the exact steps:
git clone --depth 1 https://github.com/containers/libkrunfw && make -C libkrunfw && sudo make -C libkrunfw install
git clone --depth 1 https://github.com/containers/libkrun && make -C libkrun && sudo make -C libkrun install
sudo ldconfigbuild.rs finds libkrun via pkg-config libkrun (or the standard lib dirs; override with
LIBKRUN_PREFIX=/path). There's nothing to sign — make build skips the codesign step on Linux.
Some BSD image-prep steps (losetup/mount) need root, and bsdkrun runs them with sudo
automatically when needed.
On Linux the CI boots the
linux(OCI),netbsd, andfreebsd(both direct-kernel, PVH) paths — on x86_64 only, since GitHub's arm64 runners have no/dev/kvm; arm64-on-Linux (which reuses the aarch64 kernel + agent) is validated on a KVM-capable host. The BSD-under-KVM amd64 boots go via our PVH libkrun fork, which the CI builds.
Build
git submodule update --init library/solo5 # the Solo5 tender is built from source
make build # cargo build (debug) [+ codesign on macOS]
make release # cargo build --release [+ codesign on macOS]The submodule is only needed for bsdkrun solo5, and only for a cargo build — the Nix flake
pins the same commit as a flake input instead. Without it the build still succeeds: it prints a
warning and leaves the tender out, and bsdkrun solo5 then says so rather than failing
obscurely. Building the tender needs make and, on Linux, libseccomp headers.
The two pins must name the same commit. Bump them together with
git submodule update --remote library/solo5 && nix flake update solo5; thee2e-solo5workflow fails if they drift.
make run ARGS="..." builds and runs in one step.
⚠️ macOS: don't run
cargo buildthen the binary directly — it'll be unsigned and fail at boot with errno 22. Go throughmake, or re-runmake signafter a barecargo build. On Linux there's nothing to sign, socargo buildis fine (themakesign steps are no-ops there).
The build.rs locates libkrun via brew --prefix libkrun (macOS) or pkg-config
(Linux), override with LIBKRUN_PREFIX=/path, and embeds an rpath so the shared library resolves
at runtime.
Monorepo dev console (./console)
Not to be confused with the guest serial console
below — this is a contributor tool. ./console (or cd tools/console && clj -M:rebel) drops you
into a Clojure REPL that centralizes every build/test/publish command in this monorepo — the
Makefile targets above, all six SDKs' test/build/publish steps, and web/desktop's dev
servers — as plain functions ((build/release), (sdk/test :clojure), (sdk/publish :ruby),
(web/dev)) instead of remembering which tool owns which command. bb help from
tools/console works the same way without a REPL. See
tools/console/README.md.
Usage
bsdkrun [--log-level N] <command>
Global:
--log-level N log verbosity, 0=off .. 5=trace (default 1)
--log-level drives two things at once: libkrun's internal logging and bsdkrun's own logging
(via the tracing crate). Both go to stderr, so they never mix with
the guest console on stdout. 0→warn, 1..3→info, 4→debug, 5→trace. Set the RUST_LOG
environment variable to override bsdkrun's filter with anything
EnvFilter
accepts (e.g. RUST_LOG=bsdkrun=debug). For a clean guest console, use --log-level 0 or 1.
probe — sanity check the toolchain
Verifies that libkrun links and that a context can be created and configured. Does not boot (so it won't exercise the hypervisor entitlement — see the note above).
make run ARGS="probe"
freebsd / netbsd — one-liner BSD microVMs
The quickest way to a BSD guest — fetches the image (and, for NetBSD, the kernel) if needed, then boots it:
bsdkrun freebsd # bundled FreeBSD image (agent baked in): EFI on macOS, PVH on Linux/amd64
bsdkrun freebsd --version 14.3 # official FreeBSD 14.3 VM image from download.freebsd.org
bsdkrun netbsd -d # NetBSD-current in the background; prints its id
bsdkrun netbsd --version 10.1 -d --port 2222:22Like bsdkrun linux, you can pass a command to run inside the guest after --. The guest
boots, its agent runs the command (streaming stdout/stderr), and — without -d — the VM powers
off afterward, with bsdkrun exiting on the command's status (a one-shot, à la docker run). With
-d the machine is left running once the command returns. Needs networking (the agent), so it's
incompatible with --no-net:
bsdkrun freebsd -- uname -a # boot, run, print, power off; exit = command's status
bsdkrun netbsd -- sh -c 'sysctl hw.model'
bsdkrun freebsd -d -- pkg install -y curl # run a setup step, then leave the VM upWith no command (and no -d), a foreground bsdkrun freebsd / bsdkrun netbsd drops you
straight into an interactive shell over the agent — the bundled images are headless (no console
login), so this is the way in — and powers the VM off when you exit, like foreground bsdkrun linux. A short ⋯ waiting for the guest agent line shows while the guest boots (~15-20s).
bsdkrun freebsd # boot, then an interactive /bin/sh; exit (Ctrl-D) powers it off
bsdkrun netbsd -d # no shell — just background it and print the id (use exec/ssh/shell)Add --verbose to stream the guest's boot console to stdout while it comes up (instead of
the terse spinner) — handy for watching a boot or asserting on it in CI. The command output / shell
follows once the agent is up:
bsdkrun freebsd --verbose -- uname -a # full boot log, then the command output, on stdoutBoth carry the usual machine options (-d, --persist, -v/--volume, --version,
--attach-disk, --port, --cpus/--mem), so per-machine CoW disk clones
and ps/logs/shell/stop all apply. They differ in how they boot:
-
freebsdboots differently per host OS:- On macOS it wraps
fetch+firmware: it auto-locates libkrun'sKRUN_EFIfirmware (via$BSDKRUN_FIRMWARE, a localimages/KRUN_EFI.fd, or krunkit's Homebrew install;--firmwareoverrides). By default (on arm64) it downloads bsdkrun's bundled image with the guest agent pre-installed, soexec/shellwork out of the box; pass--version Xto boot an official FreeBSD VM image from download.freebsd.org instead (no agent — you'd install it manually). - On Linux/amd64 it PVH-direct-boots bsdkrun's bundled agent-injected UFS rootfs + the
FreeBSD
FIRECRACKERkernel (no ACPI, MPTable enumeration, virtio-mmio + serial built in; built from source by the image workflow). Needs the PVH libkrun fork; override the kernel command line with$BSDKRUN_FREEBSD_CMDLINE.
- On macOS it wraps
-
netbsdwrapsfetch+ a direct kernel boot — no firmware — so it works on macOS and Linux.
firmware — boot a disk through its UEFI loader
The explicit form (the freebsd/netbsd shortcuts wrap it). Point it at libkrun's EDK2 firmware
and a raw disk image that carries an EFI System Partition:
make release
./target/release/bsdkrun firmware \
--firmware "$(brew --prefix)/share/krunkit/KRUN_EFI.silent.fd" \
--disk images/fbsd15.raw \
--cpus 2 --mem 2048- Use libkrun's own
KRUN_EFI.silent.fdfirmware — not a generic QEMUAAVMF/edk2build. libkrun uses its own guest memory layout, and only its firmware matches it. The.silentvariant just suppresses EDK2's own console chatter; the guest OS console still comes through the serial console (see Console below). - The disk is attached read-write as a virtio-blk device (
block_id = "root").
This path is confirmed booting FreeBSD 15 / arm64 all the way to a login: prompt. Two
things make it work: bsdkrun wires the guest's serial console to your terminal (see below), and
the FreeBSD image needs a one-line console hint on its ESP (see
Preparing a FreeBSD arm64 image).
kernel — boot a kernel directly (no bootloader)
The path for NetBSD/evbarm and bare kernel experiments. libkrun generates the FDT and jumps into the kernel:
./target/release/bsdkrun kernel \
--kernel path/to/netbsd \
--format elf \
--cmdline "console=..." \
--disk images/root.raw \
--cpus 1 --mem 512--format is one of elf (default) or raw. --initramfs is optional.
linux — run an OCI image as a microVM
Run any Docker Hub / OCI image as a Linux microVM, docker run-style. bsdkrun fetches a prebuilt
aarch64 kernel (cached), pulls the image for linux/arm64, extracts its rootfs, and boots it:
# run alpine's default shell
./target/release/bsdkrun linux alpine
# run a specific command, with more RAM
./target/release/bsdkrun linux alpine --mem 1024 -- /bin/sh -c 'uname -a; cat /etc/os-release'
# any registry / tag
./target/release/bsdkrun linux ghcr.io/owner/name:tagHow it works:
-
Kernel — a prebuilt aarch64
vmlinuxis downloaded from vmlinux-builder and cached. libkrun's aarch64 loader needs the rawImageformat, so bsdkrun flattens the ELF to anImage(in pure Rust — no binutils) and caches that too. Pick a release with--kernel-version(default7.1.8), or point at your own kernel with--kernel /path(ELF or rawImage). -
Rootfs — the OCI image is pulled straight from the registry (no Docker daemon; just
curl+tar) and its layers are extracted, applying whiteouts. The result is cached, content-addressed by image digest, so a repeat run is instant. By default the rootfs is packed into an initramfs and booted from RAM, with a generated/initthat mounts/proc+/sys, configures networking, and runs the image's Entrypoint/Cmd (honoringEnv/WorkingDir). -
Entrypoint — Docker semantics: args after
--replace the imageCmd;--entrypointreplaces the Entrypoint. When the workload exits, the VM powers off cleanly. -
Networking — on by default (see Networking); the guest gets internet access
(ICMP/DNS/TCP) via gvproxy, configured through the kernel command line so the image itself needs
no
ip/dhcptools.--no-netdisables it.
Notes:
-
Rootfs — by default the rootfs is served from disk over virtio-fs (no RAM-size limit, so
it's fine for large images). bsdkrun clones the cached rootfs per machine with an APFS
copy-on-write clone (
cp -Rc— instant, no extra disk until the guest writes), so machines stay isolated and the shared image cache stays pristine. It boots our own init from the shared root (not libkrun'sinit.krun, which only works with the bundled libkrunfw kernel). Needs a guest kernel withCONFIG_VIRTIO_FS=y(note:CONFIG_FUSE_FSalone is not enough — the default prebuilt kernel has it). -
--initramfsboots from an initramfs instead (the whole rootfs is loaded into RAM). Use it for a kernel without virtio-fs. Then size--memabove the image size — bsdkrun warns if it looks too small. - Either way the image must have a
/bin/sh(scratch/distroless images won't boot this way), and the console defaults tohvc0(libkrun's virtio-console;--consoleoverrides it).
Case-sensitive Linux storage on macOS
Linux filesystems are case-sensitive, but the default macOS APFS boot volume usually is not. OCI
images and Linux workloads can contain paths that differ only by case—for example, the Linux
kernel contains both ipt_ECN.h and ipt_ecn.h. Serving a rootfs directly from a case-insensitive
directory through virtio-fs would merge those files and silently corrupt the guest filesystem.
On macOS, bsdkrun therefore requires a case-sensitive backing store for OCI image trees, writable
machine roots, and named volumes. The first bsdkrun linux ... invocation creates it automatically
as a case-sensitive APFS sparsebundle and mounts it below the bsdkrun cache. A 200 GiB capacity is
the default ceiling, but the sparsebundle initially consumes only the blocks it actually uses.
Linux hosts already use case-sensitive filesystems and do not create this store.
Manage or inspect the store explicitly with:
bsdkrun store status
bsdkrun store init --size 300g # optional manual initialization/custom ceiling
bsdkrun store detach # all machines using it must be stopped
bsdkrun store attach
bsdkrun store rm --force # destructive: removes cached images and volumesInitialization refuses to migrate storage while a machine is running. Stop active machines and retry. Existing OCI cache trees are discarded rather than migrated because extraction on a case-insensitive filesystem may already have corrupted them; images are pulled again into the new store. Existing Linux machines created before the store should also be removed and recreated so they cannot resume a damaged rootfs:
bsdkrun stop <id>
bsdkrun rm <id>
bsdkrun linux alpineVirtio-fs remains the guest transport. The sparsebundle fixes the semantics of its host-side backing filesystem; it does not replace virtio-fs or load the rootfs into guest memory.
systemd — turn an OCI guest into a full systemd system
OCI images boot with bsdkrun's tiny generated /init as PID 1 — great for docker run-style
workloads, but no services, journal, or timers. One command flips a debian/ubuntu/fedora
guest to real systemd:
id=$(bsdkrun linux -d -v dev debian -- sleep infinity)
bsdkrun systemd $id setup # installs systemd if missing + the agent unit, marks the rootfs
bsdkrun stop $id
id=$(bsdkrun linux -d -v dev debian) # same volume -> boots systemd as PID 1
bsdkrun systemd $id status # "PID 1: systemd"setup installs systemd where missing (apt-get/dnf — Alpine has no systemd and fails with a
clear message), writes + enables a bsdkrun-agent.service unit (so exec/shell keep working
under systemd), and drops a marker (/etc/bsdkrun-systemd) that makes the generated init exec
systemd as PID 1 on the next boot. disable removes the marker. In systemd mode the image
entrypoint/-- command is not run — systemd owns userspace; manage workloads as units. Boot on a
volume (-v) so the installed packages + marker survive across machines.
unikraft — boot a Unikraft unikernel
Unikraft builds your application into the kernel: one binary, no userland, no init, no shell. A hello-world image is ~220 KB and boots in milliseconds. bsdkrun runs those as microVMs the same way it runs everything else.
Build for the Firecracker platform (--plat fc) — its memory layout and boot protocol are
what libkrun implements — then point bsdkrun unikraft at the result.
Step by step
1. Install kraft (Unikraft's build tool):
brew install unikraft/cli/kraftkit # macOS
curl -sSfL https://get.kraftkit.sh | sh # Linux2. Get an example from the catalog. unikraft/catalog
holds ready-made apps — library/helloworld is the smallest (one main.c):
git clone --depth 1 https://github.com/unikraft/catalog
cd catalog/library/helloworld3. On arm64, add the PL011 console driver. libkrun's aarch64 serial is an ARM PL011;
Firecracker's is an ns16550, so a stock fc/arm64 build boots silently. Both drivers probe the
device tree, so enabling PL011 alongside the default costs nothing and gives you one image that
boots under either VMM. Replace the Kraftfile with:
spec: v0.6
name: helloworld
unikraft:
version: stable
kconfig:
CONFIG_LIBPL011: 'y'
CONFIG_LIBPL011_EARLY_CONSOLE: 'y'
targets:
- fc/arm64On x86_64 no override is needed — the fc platform already uses an ns16550 on COM1, which is
the serial port libkrun exposes. Just list fc/x86_64 as the target.
4. Build it. The Unikraft build needs GNU make/sed and a Linux toolchain, so on macOS run it
in a container (kraft itself works natively, but the Unikraft tree it drives does not):
# Linux
kraft build --arch arm64 --plat fc # or --arch x86_64
# macOS — build inside Linux, the image is written to .unikraft/build/
docker run --rm -v "$PWD":/w -w /w debian:bookworm sh -c '
apt-get update -qq && apt-get install -y -qq --no-install-recommends \
build-essential libncurses-dev libyaml-dev flex bison git wget unzip \
uuid-runtime python3 curl ca-certificates bc >/dev/null
curl -sSfLo /tmp/k.deb https://github.com/unikraft/kraftkit/releases/download/v0.12.15/kraftkit_0.12.15_linux_arm64.deb
dpkg -i /tmp/k.deb
kraft build --arch arm64 --plat fc --log-type basic'5. Boot it:
bsdkrun unikraft .That's it:
Powered by
o. .o _ _ __ _
Oo Oo ___ (_) | __ __ __ _ ' _) :_
oO oO ' _ `| | |/ / _)' _` | |_| _)
oOo oOO| | | | | (| | | (_) | _) :_
OoOoO ._, ._:_:_,\_._, .__,_:_, \___)
Ijiraq 0.21.0
Hello from Unikraft!
Usage
The argument is a project directory (bsdkrun finds the image under .unikraft/build/) or the
image itself:
bsdkrun unikraft # the current directory
bsdkrun unikraft ~/catalog/library/helloworld # a project directory
bsdkrun unikraft .unikraft/build/helloworld_fc-arm64 # the image
bsdkrun unikraft -d . # detached; use logs/stop/rm
bsdkrun unikraft . --cmdline "helloworld --port 8080" # cmdline arrives as argv
bsdkrun unikraft . --initramfs initrd.cpio # for a --rootfs build
bsdkrun unikraft . --mount ~/data:/data # persistent volumeA unikernel has no shell and no agent, so exec and shell don't apply — logs, ps, stop,
start, and rm all work as usual. When main returns, the guest powers the VM off.
Volumes
--mount HOST:GUEST (repeatable) shares a host directory into the guest over virtio-fs, so
data written there survives the VM. The guest path must be absolute.
bsdkrun unikraft . --mount ~/pgdata:/var/lib/datavirtio-fs is the only option that works: libkrun has no virtio-9p (what kraft run -v uses under
QEMU), and while both sides have virtio-blk, Unikraft's core registers only ramfs and virtiofs
as filesystems — a raw disk gives you blocks, not a mountable tree.
The unikernel has to be built for it (CONFIG_LIBUKFS_VIRTIOFS, CONFIG_LIBPOSIX_VFS_FSTAB_USER,
CONFIG_LIBUKFS_RAMFS), and unikraft 0.21.0 needs two one-line upstream fixes before a virtio-fs
guest works at all. examples/unikraft-volume is a working setup with
both patches and a fetch → patch → build script; its README explains each.
bsdkrun generates the mount table onto the kernel command line — a program name, the vfs.fstab
entries, then -- — and mounts a ramfs at / first so the mountpoints have somewhere to exist.
commit still doesn't apply: the data lives in a host directory, so there is no disk image to
snapshot.
How it boots
The fc platform links at 0x8000_0000 (arm64), which is exactly where libkrun's aarch64 loader
places a raw image, and it boots via the Linux protocol with the device tree in x0 — which is
what libkrun sets up. Two things bsdkrun handles for you:
-
The entry shim (arm64). Unikraft reserves the first megabyte of RAM for the DTB, so its
Imageheader asks to be loaded at0x8000_0000 + 0xf_ffc0, and the build is not relocatable. libkrun ignorestext_offsetand enters at0x8000_0000— inside the reserved hole. bsdkrun front-pads the image so every byte lands at its link address and writes a singlebinstruction at offset 0, so libkrun's fixed entry branches to the real one (a branch preservesx0). The shimmed image is cached under$XDG_CACHE_HOME/bsdkrun/unikraft, keyed by content. -
The image format. arm64 takes the raw
Image(or the.dbgELF beside it — same bytes once flattened); x86_64 hands the ELF straight to libkrun's ELF loader, which enters ate_entry(_lxboot_entry) in long mode with the zero page inRSI— exactly the 64-bit Linux protocol Unikraft'sfcplatform expects, including theHdrSmagic it checks before it will run.
Booting on arm64/macOS is verified end to end; x86_64 is covered by the
e2e-unikraft CI workflow, which builds a catalog unikernel
with kraft and boots it under KVM.
arm64 needs a patched libkrun. Unikraft's PL011 driver writes its registers 16 bits at a time (
strh), and libkrun's HVF MMIO path only handled 1/4/8-byte writes — a halfword write panicked the vCPU thread withunsupported mmio len=2. The read path already handled it; the fix is the matching2 =>arm in the write path. Upstreaming is in progress; until it lands, build libkrun fromtsirysndr/libkrun.
nanos — boot a Nanos (NanoVMs) unikernel
Nanos implements the Linux syscall ABI, so the guest starts as an ordinary
static Linux binary and ops wraps it into a bootable image. bsdkrun nanos boots that image
directly — pass either a path, or a bare name from ~/.ops/images/ (what ops build -i <name>
produces):
curl https://ops.city/get.sh -sSfL | sh # install ops
./build.sh # builds ~/.ops/images/nanos-hello
bsdkrun nanos nanos-hello --no-netLike every unikernel, a Nanos guest has no shell and no agent: exec, shell, and commit do
not apply; logs, ps, stop, start, and rm work as usual.
Status
-
Linux/x86_64 boots via direct kernel load — the same Firecracker-style contract Nanos
supports officially.
bsdkrun nanosauto-finds the newest~/.ops/<version>/kernel.imgthatops buildstaged, or you can override it with--kernel. -
macOS/arm64 uses EFI boot with
KRUN_ACPI=1and needs an image built with"Uefi": true. It boots to userspace with a patched Nanos kernel + bootloader (nanos fork, branchfix/aarch64-libkrun-boot, staged into~/.ops/<version>-arm/) and the libkrun fork's level-SPI ack fix; stock 0.1.55 dies before userspace. Seeexamples/nanos-hellofor the five root causes and the staging steps. - Linux/arm64 is not bootable today: the Nanos kernel links below libkrun's direct-kernel RAM base, and Linux hosts have no EFI path for it.
Usage
bsdkrun nanos nanos-hello # name in ~/.ops/images
bsdkrun nanos ~/.ops/images/nanos-hello # explicit image path
bsdkrun nanos -d nanos-hello --no-net # detached; use logs/stop/rm
bsdkrun nanos nanos-hello --kernel ~/.ops/0.1.55/kernel.img
solo5 / mirage — run a MirageOS (Solo5) unikernel
MirageOS unikernels run on Solo5, whose
hvt tender is itself the hypervisor front end. So this guest is the one exception in
bsdkrun: it does not go through libkrun. bsdkrun builds the tender from the pinned
library/solo5 submodule at compile time and embeds it, so running a unikernel needs no
Solo5 install of your own — only building one does.
cd examples/mirage-hello && ./build.sh # needs opam + mirage
bsdkrun solo5 dist/hello.hvt --mem 128 --port 18080:8080
curl http://127.0.0.1:18080/ # Hello from MirageOS on bsdkrunbsdkrun mirage is a visible alias for the same command.
Note what the command line does not carry: no device names, no MAC, no IP, no gateway. Every
Solo5 unikernel declares the devices it wants in its own binary (the MFT1 ELF note), and the
tender refuses to boot unless each one is attached — so bsdkrun reads the manifest and attaches
them itself:
INFO running Solo5 unikernel id=4bc3fd4a9851 image=hello.hvt nets=["service"] blocks=[]
The network reaches the outside through gvproxy. bsdkrun opens that socket itself and hands the
tender the descriptor (--net:service=@3) — on macOS this is the only way to attach a network
at all, there being no TAP device — and pins the guest's MAC so gvproxy's DHCP lease always
lands on the address --port forwards to.
A declared block device is backed with --block:
bsdkrun solo5 dist/store.hvt --block storage=disk.imgLeave it out and bsdkrun names the missing device before starting anything.
Like every unikernel, a Solo5 guest has no shell and no agent: exec and shell do not apply;
ps, logs, stop and rm work as usual, and stop kills the tender rather than just
bsdkrun, so nothing is left holding the ports.
Status
-
macOS/arm64 (Hypervisor.framework) — verified: builds, boots, leases a DHCP address and
serves over a forwarded port. The HVF backend comes from
tsirysndr/solo5
hvf-macos-aarch64, which upstream Solo5 does not have. Two gaps it documents rather than papers over: the tender drops no privileges on macOS (no seccomp/pledge/capsicum equivalent), and there is nosolo5-hvt-debug— the gdb and dumpcore backends are unported. -
Linux (KVM) — the upstream backend, exercised by the
e2e-solo5workflow, which asserts on the HTTP body the unikernel serves.
Usage
bsdkrun solo5 dist/hello.hvt # explicit unikernel
bsdkrun solo5 . # a project dir; finds dist/*.hvt
bsdkrun mirage dist/hello.hvt --port 8080:8080
bsdkrun solo5 -d dist/hello.hvt # detached; use logs/stop/rm
bsdkrun solo5 dist/hello.hvt -- --extra-arg # arguments for the unikernel itselfSee examples/mirage-hello for the full walkthrough.
Managing machines
bsdkrun keeps a small SQLite database (sqlx) under $XDG_STATE_HOME/bsdkrun recording the
machines you run, the images you've pulled, and disks you've attached — each with a Docker-style
short id. The same Docker-like commands work for every guest type — Linux (linux) and BSD
(firmware / kernel) alike — bsdkrun records each machine's kind and applies the right logic:
# run any machine in the background; prints its id
id=$(bsdkrun linux -d alpine)
id=$(bsdkrun firmware -d --firmware images/KRUN_EFI.fd --disk images/fbsd15.raw)
bsdkrun ps # list running machines (-a for all, incl. exited)
bsdkrun images # list images: pulled OCI images + fetched BSD images
bsdkrun logs $id # print the machine's console log
bsdkrun logs -f $id # follow it live
bsdkrun exec $id uname -a # run a command inside the guest (-t for a PTY, -e K=V for env)
bsdkrun shell $id # open an interactive shell in the guest
bsdkrun stop $id # stop a running machine (BSD guests clean-poweroff first)
bsdkrun start $id # re-boot a stopped machine in place — resumes its own disk/rootfs
bsdkrun update $id --cpus 4 --mem 2048 # change recorded vCPU / RAM (applies on next start)
bsdkrun rm $id # remove a machine and its state (-f stops it first)Any unique id prefix works (bsdkrun stop 8e1c). shell attaches to the guest console: for a
Linux machine that's an interactive shell (with exit/re-attach); for BSD it's the guest's own
console (e.g. the login: prompt).
stop/start persist your data. A start resumes the machine's own disk/rootfs — the
committed snapshot it was booted from plus any runtime changes — not a fresh copy of the base
image, so stopping and starting keeps your data (like docker start). For BSD guests, stop
cleanly powers the guest off (shutdown -p now) so its UFS is unmounted and consistent before the
next boot — this takes a few seconds; without it a killed live UFS would be torn and fsck would
discard recent writes. (A brand-new run without -v/--persist is still ephemeral — see below.)
Clone a repo on boot (--repo) — pass a git URL to any run and bsdkrun clones it into the
guest after boot (installing git first if the base lacks it — apt/apk/dnf/pacman/pkg/pkgin/…) and
drops you into it when you open a shell:
bsdkrun linux -d --repo https://github.com/owner/app node:22 # clone into ~/app, cd on shell
bsdkrun freebsd -d --repo https://github.com/owner/appCopy-on-write disks — a BSD machine's root disk is cloned per machine with an APFS clonefile
(cp -c — instant, and costs no extra disk until the guest writes), so you can boot many
microVMs from one base image concurrently without touching it. Pass --persist to boot the disk
in place instead (writes persist; one machine at a time). Linux machines get the same isolation via
their per-machine virtio-fs clone.
Persistent volumes (-v NAME) — by default every boot starts from a fresh clone, so guest
changes are thrown away when the machine exits. To keep them across reboots, name a volume — works
the same for Linux, FreeBSD and NetBSD:
bsdkrun linux -d -v web alpine # persistent Linux rootfs
bsdkrun freebsd -d -v db # persistent FreeBSD diskReuse the same -v NAME and the machine comes back up with your changes intact. It's a single
writer at a time (run one machine per volume), and it's mutually exclusive with --persist (which
writes to the base image itself). The mechanism is the same idea for every guest — a copy-on-write
clone that persists:
-
Linux serves the volume as a writable virtio-fs root. First use copy-on-write clones the
OCI image into the volume dir (
clonefileon APFS /reflinkon btrfs/xfs — instant, and only grows as the guest writes; a plain copy elsewhere); later boots reuse it, so your changes persist. Needs the default virtio-fs (not--initramfs, a RAM disk with nothing to persist). (Earlier builds layered overlayfs over virtio-fs, but the Linux/KVM kernel rejects a virtio-fs overlay upperdir, so a plain writable clone is used instead — it works identically on macOS and Linux.) -
FreeBSD / NetBSD use an APFS copy-on-write clone of the disk image under
<state>/volumes/<NAME>(instant, and only grows as the guest writes).
Volumes are recorded in the state DB and managed Docker-style:
bsdkrun volume ls # NAME, GUEST, BASE, SIZE (du, CoW-aware), CREATED
bsdkrun volume rm web # delete a volume's data (refused if a machine is using it)
bsdkrun volume rm -f web db # force removal / multiple namesBind-mount host directories (--mount, Linux only) — share a host directory into the guest at
a path, like docker run -v. Repeatable; append :ro for read-only:
bsdkrun linux --mount ~/project:/src --mount ~/data:/data:ro alpine -- ls /srcEach --mount HOST:GUEST[:ro] becomes a virtio-fs share the generated init mounts at GUEST
(the host dir must exist; GUEST must be absolute). Reads and writes pass straight through to the
host, and it composes with -v (persistent volume) and every Linux root mode.
Linux guests can also attach raw disk images as virtio-blk devices with
--attach-disk PATH[:ro] (repeatable) — see Disks.
How it works — no daemon:
-
-ddetached — bsdkrun forks; the childsetsids, wires the guest console (hvc0) to a per-machine PTY, and a broker thread fans that PTY out toconsole.logand a Unix socket (console.sock) under…/machines/<id>/. The parent records the machine (with the child's pid) and prints the id. (libkrun's implicit console only writes to a tty — a plain pipe/socket would just get logged — which is why the console is a PTY.) -
logsreadsconsole.log;-fthen streamsconsole.sock. -
exec/shellrun a new process in the guest through an in-guest agent (see below) —docker exec, notdocker attach.execforwards stdin/stdout/stderr and the exit code (-tallocates a PTY,-e K=Vsets env);shellisexec -t /bin/sh. When a machine has no agent,shellfalls back to attaching the guest console overconsole.sock(raw-mode proxy, the recent console replayed on attach, Ctrl-] to detach). -
Persistence — a detached machine with no explicit command (
bsdkrun linux -d alpine) keeps a console shell alive: typingexit(or Ctrl-]) returns you to your host prompt and leaves the machine running — re-attach any time withshellfor a fresh shell; the machine ends only when youstopit. Give it a command (… -d alpine -- myserver) and it behaves like Docker instead — the machine powers off when that command exits. -
stopsendsSIGTERMto the machine's process (whose signal handler tears down gvproxy). -
psreconciles: a machine still marked running whose process is gone is shown as exited.
The exec agent
exec/shell talk to a tiny in-guest agent that listens on TCP port 1024 and runs one
command per connection over a small framed protocol (stdin/stdout/stderr + exit code, optional
PTY). There's no vsock dependency: bsdkrun forwards a per-machine host port to the guest through
gvproxy, so the same mechanism works for Linux and BSD. The agent needs the guest's network up
(gvproxy leases it 192.168.127.2), so exec/shell require networking (not --no-net).
-
Linux — bsdkrun downloads the aarch64 agent from the GitHub release (cached under
~/.cache/bsdkrun/agent/), injects it into the rootfs, and starts it on boot. Nothing to do. (PointBSDKRUN_AGENT_LINUXat a local binary, orBSDKRUN_AGENT_VERSIONat a different tag, to override.) - FreeBSD / NetBSD — bsdkrun can't write the guest's UFS/FFS from macOS, so you install the agent yourself, once, inside the running guest.
BSD setup (as root — the default no-password user). Download the matching binary from the release into the guest, which has internet by default:
# FreeBSD (fetch is built in):
fetch -o /usr/local/sbin/bsdkrun-agent \
https://github.com/tsirysndr/bsdkrun/releases/download/v0.1.0/bsdkrun-agent.freebsd-aarch64
# NetBSD (ftp speaks https):
ftp -o /usr/local/sbin/bsdkrun-agent \
https://github.com/tsirysndr/bsdkrun/releases/download/v0.1.0/bsdkrun-agent.netbsd-aarch64
chmod +x /usr/local/sbin/bsdkrun-agent
/usr/local/sbin/bsdkrun-agent & # start it now; listens on TCP :1024From the host, bsdkrun exec <id> uname -a now works. The quickest way to start it on every boot
is a line in /etc/rc.local:
/usr/local/sbin/bsdkrun-agent &As a proper service — drop in an rc.d script (both are in packaging/):
/usr/local/etc/rc.d/bsdkrun_agent#!/bin/sh
#
# PROVIDE: bsdkrun_agent
# REQUIRE: NETWORKING
# KEYWORD: shutdown
. /etc/rc.subr
name="bsdkrun_agent"
rcvar="bsdkrun_agent_enable"
load_rc_config $name
: ${bsdkrun_agent_enable:="NO"}
: ${bsdkrun_agent_program:="/usr/local/sbin/bsdkrun-agent"}
pidfile="/var/run/${name}.pid"
# daemon(8): -f background, -P track pid, -r restart the agent if it exits.
command="/usr/sbin/daemon"
command_args="-f -P ${pidfile} -r ${bsdkrun_agent_program}"
run_rc_command "$1"chmod +x /usr/local/etc/rc.d/bsdkrun_agent
sysrc bsdkrun_agent_enable=YES
service bsdkrun_agent start/etc/rc.d/bsdkrun_agent (no daemon(8), so we track the pid ourselves)#!/bin/sh
#
# PROVIDE: bsdkrun_agent
# REQUIRE: NETWORKING
. /etc/rc.subr
name="bsdkrun_agent"
rcvar=$name
command="/usr/local/sbin/bsdkrun-agent"
pidfile="/var/run/${name}.pid"
start_cmd="agent_start"
stop_cmd="agent_stop"
agent_start()
{
echo "Starting ${name}."
${command} &
echo $! > ${pidfile}
}
agent_stop()
{
if [ -f ${pidfile} ]; then
kill "$(cat ${pidfile})" 2>/dev/null && rm -f ${pidfile}
fi
}
load_rc_config $name
run_rc_command "$1"chmod +x /etc/rc.d/bsdkrun_agent
echo 'bsdkrun_agent=YES' >> /etc/rc.conf
/etc/rc.d/bsdkrun_agent startIf exec times out, check inside the guest that networking is up (ifconfig shows
192.168.127.2) and the agent is listening (netstat -an | grep 1024).
The FreeBSD binary is dynamically linked for FreeBSD 14+; the NetBSD binary is built natively on NetBSD 10.
Flavors — preconfigured environments & snapshots
A flavor is a named, ready-to-boot environment. Launch one with a single command; the first run
provisions and caches it, and every later launch clones that cache — so it's instant after the first
build, like docker build + docker run:
bsdkrun flavors # list the catalog + your snapshots + your flavors.toml entries
bsdkrun flavor run -d node # boot Node.js 22
bsdkrun flavor run -d claude-code # boot an AI coding agent (Claude Code) — provisioned once
bsdkrun flavor build laravel # pre-build a flavor's cache (streams the provisioning)Flavors come from three places:
-
Catalog — curated, built-in environments across several categories:
-
languages / runtimes:
node,python(uv),php(composer),laravel,symfony,elixir,phoenix,gleam(nix),clojure,nix,docker -
AI coding agents:
claude-code,codex,opencode,crush,copilot -
services:
postgres,mysql,redis· web:nginx,apache,caddy -
operating systems:
freebsd,netbsd
-
languages / runtimes:
-
User — your own stacks, declared in a
flavors.toml($BSDKRUN_FLAVORS_FILE, else./bsdkrun.flavors.toml, else~/.config/bsdkrun/flavors.toml):[[flavor]] name = "my-api" base = "node:22" # an OCI ref, or `freebsd` / `netbsd` category = "language" ports = ["3000:3000"] env = ["NODE_ENV=development"] provision = ["apt-get update && apt-get install -y git", "npm install -g pnpm"]
Manage them from the CLI too:
bsdkrun flavor add my-api --base node:22 --provision "npm i -g pnpm"andbsdkrun flavor rm my-api. -
Snapshots — freeze a running (or stopped) machine's current state into a reusable flavor, like
docker commit. Boot FreeBSD, install your tools, then capture it:id=$(bsdkrun freebsd -d) bsdkrun exec $id pkg install -y git tmux vim bsdkrun commit $id my-freebsd-dev --description "FreeBSD with my toolchain" bsdkrun flavor run -d my-freebsd-dev # clone that exact state into a fresh machine
Snapshots are CoW clones (rootfs for Linux, the raw disk for BSD), so they're cheap to store and boot.
Build methods are shown per flavor so you know how it's provisioned: docker (a plain OCI
image), nix (packages via the Determinate Systems installer), or system (packages/shell
provisioning in the guest). Provisioning steps compose with -v volumes, --port, and --repo.
Networking
firmware, kernel, and linux all give the guest internet access by default — no flags
needed. libkrun's built-in TSI backend only works for Linux guests via an in-guest shim, so we
instead give every guest a real virtio-net NIC wired to gvproxy,
a userspace network stack that NATs the guest out to your host's network. The guest DHCPs an
address on 192.168.127.0/24 (gateway .1, guest .2) with working DNS.
Install gvproxy once:
brew install gvproxyIf gvproxy isn't installed, bsdkrun prints a warning and boots the guest without a NIC (unless
you asked for --port, which then hard-errors). Disable networking explicitly with --no-net.
Each VM gets its own gvproxy instance and its own isolated network, so you can run several
guests at once. gvproxy is torn down automatically when the VM exits — including when you interrupt
it (Ctrl-C / kill), via a signal handler that also restores your terminal.
Global networks — reach machines by name
By default each machine is isolated. Opt in to a shared network so machines share one subnet and reach each other by IP and by name (docker-compose style), with internal DNS:
bsdkrun network create devnet # one shared gvproxy switch
bsdkrun linux -d --network devnet --name db postgres
bsdkrun linux -d --network devnet --name api myapi
bsdkrun exec api -- ping db # resolves db → 192.168.127.xMembers get distinct IPs on 192.168.127.0/24 and a DNS name of their --name (defaults to a
generated one). Manage networks and membership:
bsdkrun network ls # networks + running/total members
bsdkrun network connect <machine> devnet # join/switch an existing machine (applies on next start)
bsdkrun network disconnect <machine> # back to isolated (applies on next start)
bsdkrun network sync devnet # refresh members' /etc/hosts (fixes name lookup)
bsdkrun network rm [-f] devnetMembership is editable after creation and re-applied on start. Names resolve on Linux and FreeBSD
via the network's DNS; NetBSD resolves via a synced /etc/hosts block (its resolver rejects the
DNS's AAAA NXDOMAIN for A-only names) — joins auto-sync, and network sync refreshes an existing
network without restarting members.
SSH into the guest
gvproxy forwards a unique host port to the guest's SSH (:22) for each VM. bsdkrun logs it at
boot:
INFO networking up — SSH into the guest with: ssh -p 58851 user@127.0.0.1
(The guest must be running sshd and permit your login, of course.)
Forwarding extra ports
--port HOST:GUEST (repeatable) forwards a host TCP port into the guest:
./target/release/bsdkrun firmware \
--firmware "$(brew --prefix)/share/krunkit/KRUN_EFI.silent.fd" \
--disk images/fbsd15.raw \
--port 8080:80 --port 2222:22 \
--cpus 2 --mem 2048--mac AA:BB:CC:DD:EE:FF overrides the guest NIC's MAC (default: a fixed locally-administered one).
SSH — key-based access in one command
The guest agent also manages sshd on Linux, FreeBSD and NetBSD guests. The one-liner
installs your local ~/.ssh/id_*.pub keys, installs sshd where the OS lacks it (Linux OCI
guests — the BSDs ship it in base), generates host keys, and enables + starts the service:
bsdkrun ssh <id> setup # your local public keys, root login
ssh -p <port> root@127.0.0.1 # port from `bsdkrun ps` / the boot banner
bsdkrun ssh <id> setup --key ~/.ssh/work.pub --user tsiry # explicit key / other user
bsdkrun ssh <id> add-key --key "ssh-ed25519 AAAA..." # append a key later
bsdkrun ssh <id> status # sshd state + installed key count--key takes a literal public key or a local .pub file path (the wrapper inlines file
contents before sending). Keys are deduplicated by key material, written with the modes sshd
insists on (700/600, owned by the target user), and an explicit PermitRootLogin no is
relaxed to prohibit-password — key-only, never passwords.
Tailscale — put a guest on your tailnet
The guest agent doubles as a tailscale manager on Linux, FreeBSD and NetBSD guests — one
command installs tailscale the OS-native way, starts tailscaled, and joins the tailnet:
# one-shot: install + start + `tailscale up` (get an auth key from the admin console)
bsdkrun tailscale <id> setup --authkey tskey-auth-...
# or keep the key out of shell history / ps:
TS_AUTHKEY=tskey-auth-... bsdkrun tailscale <id> setup
bsdkrun tailscale <id> status # who am I / peers
bsdkrun tailscale <id> install # just install
bsdkrun tailscale <id> start # just start tailscaledNetBSD images ship tailscale pre-baked. bsdkrun's bundled NetBSD images (both amd64 and arm64) now carry the
tailscale/tailscaledbinaries plus an/etc/rc.d/tailscaledservice enabled with--tun=userspace-networking, sotailscaledis already running at boot — noinstall/startneeded. Go straight tobsdkrun tailscale <id> status(showsLogged out.on a fresh guest) or join a tailnet withbsdkrun tailscale <id> setup --authkey …. The two static Go binaries come from pkgsrc and carry no extra dependencies (they link only baselibc). Other guests (Linux OCI, FreeBSD) still install on demand via the OS-native paths below.
bsdkrun tailscale finds the in-guest agent binary itself; the equivalent explicit form is
bsdkrun exec <id> /usr/local/sbin/bsdkrun-agent tailscale ... (Linux OCI guests carry the
agent at /sbin/bsdkrun-agent).
Install goes through each OS's native channel: apk (Alpine) or the official static tarball on
Linux, pkg install on FreeBSD, pkg_add from the pkgsrc CDN on NetBSD (a no-op on the bundled
NetBSD images, where it's already baked in). tailscaled runs with
--tun=userspace-networking by default — the microVM kernels bsdkrun boots (Linux microvm,
NetBSD MICROVM, FreeBSD FIRECRACKER) generally lack tun/tap, and userspace mode still makes
the guest reachable over the tailnet (ssh, agent port, anything listening). Pass
--kernel-tun to start/setup to use a real TUN device where the kernel has one
(e.g. full Linux kernels with /dev/net/tun — detected automatically there).
Machine domains — local DNS + HTTPS
Give every machine a real, browser-trusted URL on this host:
bsdkrun linux nginx:alpine --port 18080:80 -d --name web
bsdkrun domains enable # prompts once to wire the resolver + trust the CA
curl https://web.bsdk # no -k, no warningsenable starts three cooperating pieces and wires them up — all idempotent, so
re-running it repairs whatever is missing:
| Piece | What it does |
|---|---|
| DNS responder | Built into bsdkrun; answers *.bsdk with 127.0.0.1 on a loopback port (5343). No dnsmasq needed. |
| Resolver |
/etc/resolver/bsdk on macOS, a systemd-resolved drop-in on Linux — only that TLD is routed to bsdkrun. |
| Caddy | Found on PATH (brew install caddy / apt install caddy, or $BSDKRUN_CADDY); terminates TLS with its local CA and reverse-proxies to each machine's first --port forward. |
Guests live behind gvproxy's userspace NAT, so a domain routes to a forwarded
port — a machine without --port has nothing to route to, and bsdkrun domains ls says so. Machine names become DNS labels (tidy_turing →
tidy-turing.bsdk); stopped machines keep their domain and serve Caddy's 502,
which beats NXDOMAIN as a diagnostic. New machines join automatically on boot.
domains status health-checks every piece (DNS, resolver, Caddy, CA trust);
domains disable --purge removes the resolver wiring, un-trusts the CA and
deletes the proxy state. Default TLD is bsdk; pick another with enable --tld, and non-443 ports with --https-port/--http-port (Linux gets a
one-time net.ipv4.ip_unprivileged_port_start=80 sysctl offer for the low
ports). Which machine maps to which URL and upstream port is domains ls.
Local certificates
Caddy runs its own local CA and mints a short-lived leaf per domain; the
root and its key live under <state>/proxy/data/pki/authorities/local/
(isolated from any system Caddy, and deleted by disable --purge).
bsdkrun domains ca prints the root's path, --pem the certificate itself.
Trust is installed into the system trust store once, on enable:
-
macOS —
sudo security add-trusted-cert -d -r trustRoot -k /Library/Keychains/System.keychain <root>, run through an interactive sudo prompt in your terminal. It has to be interactive:add-trusted-certsets the trust settings through an API that a sessionless root (an osascript "with administrator privileges" shell, or a GUI-launched process) cannot satisfy — it fails with "authorization was denied since no user interaction was possible," leaving the cert present but untrusted. So runbsdkrun domains enablefrom a real terminal. -
Linux —
caddy trust, which drives both the system store and the NSS store browsers use (libnss3-toolsprovides thecertutilit needs).
domains status's ca trust line checks actual trust settings
(security dump-trust-settings -d / openssl verify), not mere presence — a
half-installed cert reads as NOT OK, and re-running enable repairs it.
Tools that verify against their own bundle, not the system store. Browsers
and system curl trust the CA after enable. Toolchains that ship a bundled
CA list — Python (requests/HTTPie, via certifi), Node, Go — never consult the
keychain, so they reject the cert. Point them at the CA:
http --verify "$(bsdkrun domains ca)" https://web.bsdk # HTTPie / requests, per call
bsdkrun domains ca --pem >> "$(python3 -m certifi)" # append to certifi (persists, keeps public roots)
export REQUESTS_CA_BUNDLE="$(bsdkrun domains ca)" # requests-based tools*
export NODE_EXTRA_CA_CERTS="$(bsdkrun domains ca)" # Node*REQUESTS_CA_BUNDLE/SSL_CERT_FILE replace the bundle, dropping the public
roots for that shell — fine for talking only to .bsdk, not a login-shell
default. Appending to certifi keeps the public roots. macOS note: /usr/bin/curl
(SecureTransport) reads the keychain; a Homebrew curl (OpenSSL) does not, so
verify with the system one or point it at --cacert "$(bsdkrun domains ca)".
TUI — the terminal dashboard
bsdkrun tuiMachines, images, volumes and networks as live panels, refreshed every 1.5 s on
a background thread. The persistent bottom status line shows the current
selection, the outcome of the last action (with a spinner while one runs), and
the machine-domains health chip (https ·bsdk ✓ / domains off). Press ?
for the full keybinding list at any time.
| Key | Action |
|---|---|
Tab / S-Tab
|
Cycle panels (j/k/arrows move, g/G first/last) |
/ |
Fuzzy search across every panel (fzf-style), Enter jumps |
n |
New-machine wizard (image, name, port, cpus, mem) |
s / x
|
Start / stop the selected machine |
e |
Shell into it (suspends the TUI, resumes on exit) |
l |
Log viewer — backfills, then follows a running machine live |
i / Enter
|
Machine settings (vCPU / memory, applied on next start) |
o |
Open https://<name>.bsdk in the browser (needs domains) |
d |
Remove, with confirmation |
r |
Refresh now |
? |
Show every keybinding |
q / Ctrl-C
|
Quit |
The / search fuzzy-matches across all four panels at once (fzf-quality
ranking, matched characters highlighted); Enter jumps focus straight to the
hit. Starting a machine spawns a detached bsdkrun start — a boot forks and
becomes the machine, which must never happen inside the TUI's own process —
and stopping runs on a worker thread, so the dashboard stays responsive through
a slow graceful BSD poweroff. The alternate screen is restored on quit, on a
panic, and on SIGTERM/SIGHUP.
Disks
The root disk is attached read-write as virtio-blk (--disk). Attach additional disks with
--attach-disk (repeatable); append :ro for a read-only attachment:
./target/release/bsdkrun firmware \
--firmware "$(brew --prefix)/share/krunkit/KRUN_EFI.silent.fd" \
--disk images/fbsd15.raw \
--attach-disk images/data.raw \
--attach-disk images/blobs.raw:ro--attach-disk works on the BSD guests (freebsd/netbsd/firmware/kernel) and on linux
guests alike. Extra disks appear in the guest as the next virtio-blk devices in the order given
(e.g. FreeBSD vtbd1, vtbd2…). A linux guest's rootfs is virtio-fs — there is no root block
device — so its first attached disk is /dev/vda, the next /dev/vdb, and so on. Create a blank one with
truncate -s 8G data.raw (then partition/newfs it in the guest), or grow an existing image with
bsdkrun grow.
Console: how output reaches your terminal
This is the single most confusing thing about booting BSD under libkrun, so it gets its own section.
On aarch64/macOS, libkrun creates an implicit console that is not the legacy PL011 UART
(ttyS0, MMIO 0xa001000) that the EDK2 firmware and BSD EFI loaders actually write to. The
firmware banner, the loader menu, and the early kernel console all go to that PL011 — so with the
default implicit console you see nothing, even though the guest is booting fine.
bsdkrun fixes this for you: before boot it calls krun_disable_implicit_console() and then
krun_add_serial_console_default(input, stdout), so the explicit serial console lands on ttyS0
(the PL011 the firmware drives) and is wired to this process's stdout. See
Ctx::attach_stdio_serial_console in src/krun.rs.
The console input fd must be pollable. libkrun registers the console input fd with kqueue,
which rejects non-pollable fds — a regular file or /dev/null. Handing it stdin blindly would
therefore abort the whole process (epoll.rs: assertion left == right failed, left: -1, seen
as SIGABRT or SIGSEGV) whenever stdin isn't a terminal: output redirected to a file, run
non-interactively, or launched from a shell's !/background. To avoid that, bsdkrun uses stdin
as the console input only when it's a TTY; otherwise it substitutes the read end of an
internal pipe — a pollable fd that just never delivers input. So:
-
Interactive terminal —
bsdkrun firmware …drops you straight on the guest console and your keystrokes reach it. Just run it; no wrapping needed. -
Non-interactive / captured —
bsdkrun firmware … > boot.log 2>&1works too; you simply can't type into the guest (there's no terminal to type from). Use--log-level 0to keep libkrun's own logs out of the capture (see Troubleshooting).
If you specifically want to capture the boot and keep an interactive stdin available, the old pipe trick still works:
tail -f /dev/null | bsdkrun firmware … 2>krun.log | cat > boot.log.
Preparing a guest image
The easy way — bsdkrun fetch
fetch downloads a BSD arm64 VM image, decompresses it, prepares it for a serial console, and
links it into ./images — everything below, automated. It shells out to tools already on macOS
(curl, xz/gzip, hdiutil, diskutil).
# FreeBSD (default OS)
bsdkrun versions # list releases (14.3, 14.4, 15.0, 15.1, …)
bsdkrun fetch # latest release -> ./images/freebsd-<ver>.raw
bsdkrun fetch --version 15.1 # pin a release
bsdkrun fetch --dir /tmp --force # custom dir, re-download
# NetBSD
bsdkrun versions --os netbsd # list builds (current + releases)
bsdkrun fetch --os netbsd # NetBSD-current -> ./images/netbsd-current.img
# then boot what it printed:
bsdkrun firmware --firmware images/KRUN_EFI.fd --disk images/freebsd-15.1.raw --cpus 2 --mem 2048With no --version, FreeBSD resolves the newest release and NetBSD uses current (see the
NetBSD note below). Downloads are a few hundred MiB and expand to a couple GiB.
Downloaded images are cached under ~/.cache/bsdkrun/ (override with BSDKRUN_CACHE, or
XDG_CACHE_HOME), so fetching a version you already have is instant — it just links the cached
image into --dir (a hard link, no second copy). Use --force to re-download.
NetBSD: version handling is arch-specific
bsdkrun netbsd direct-boots the kernel (no firmware), rooting on the virtio-blk disk (override
the kernel command line with $BSDKRUN_NETBSD_CMDLINE). The details differ by host arch:
-
arm64 ✅ downloads bsdkrun's bundled evbarm image (the live
gzimgwith the agent and tailscale injected) + the evbarmGENERIC64kernel from the NetBSD CDN, rooting on the GPT wedgedk1. libkrun exposes modern (v2) virtio-mmio, and NetBSD's driver only gained v2 support in -current (post-10.x): the bundled image iscurrent, so--versiononly affects the kernel — pinning a release (≤ 10.1) kernel printsvirtio: unknown version 0x02; giving up. -
amd64 ✅ boots via PVH — but it needs a PVH-capable libkrun:
tsirysndr/libkrun
feat/pvh-boot(stock libkrun only speaks the Linux boot protocol, under which any NetBSD kernel triple-faults instantly). bsdkrun downloads its bundled FFS rootfs + the NetBSDMICROVMkernel (a PVH ELF), setsKRUN_PVH=1so libkrun enters via the kernel'sPHYS32_ENTRYnote, and boots withroot=ld0a console=com(console=commatters: a PVH boot passes no bootinfo, so without it NetBSD's console defaults to nonexistent VGA and all output vanishes). The agent and tailscale are baked into the image, soexec/shell(and a boot-timetailscaled) work out of the box. Against stock libkrun the boot triple-faults (KRUN_PVHis simply ignored) — build the fork until PVH lands upstream; see the KVM e2e, which builds the fork and boots it on every run.
Resizing the disk
The stock images are small (NetBSD's root is ~1.7 GB) — you'll hit no space left on device
quickly. grow enlarges a raw image; NetBSD then expands its root filesystem to fill the new space
automatically on the next boot:
bsdkrun grow --disk images/netbsd-current.img --size 8G
bsdkrun firmware --firmware images/KRUN_EFI.fd --disk images/netbsd-current.img --cpus 2 --mem 2048
# NetBSD's resize_root grows the GPT partition + ffs on boot; root becomes ~7.8 GB.grow only enlarges (never shrinks). Note it follows hard links, so growing a fetch-linked image
also grows the cached copy — that's usually fine (the file is sparse). FreeBSD images won't
auto-grow this way (their UFS root is followed by the swap partition, so the trailing space isn't
adjacent to root).
The manual way
FreeBSD publishes raw arm64 disk images directly:
V=15.1
base=https://download.freebsd.org/releases/VM-IMAGES/$V-RELEASE/aarch64/Latest
curl -Lo images/fbsd.raw.xz "$base/FreeBSD-$V-RELEASE-arm64-aarch64-ufs.raw.xz"
xz -d images/fbsd.raw.xz # -> images/fbsd.raw (several GB)A valid image is GPT with an EFI System Partition (type GUID C12A7328-…) plus the
FreeBSD UFS root (516E7CB5-…). You can sanity-check the partition table with xxd/gpt/
hdiutil before booting. Then set the console via the ESP as described next (or just run
bsdkrun fetch on the same version, which does it for you).
Point FreeBSD's console at the serial (via the ESP)
FreeBSD's /boot/loader.conf lives on the UFS root, which macOS can't write. Fortunately the
FreeBSD EFI loader also reads /efi/freebsd/loader.env from the ESP before it mounts
UFS — exactly where you can set the console early. macOS can mount the FAT ESP, so:
# attach the raw image and mount only the FAT ESP (disk?s1)
DEV=$(hdiutil attach -imagekey diskimage-class=CRawDiskImage -nomount images/fbsd15.raw \
| awk '/EFI/{print $1}')
diskutil mount -mountPoint /Volumes/ESP "$DEV"
mkdir -p /Volumes/ESP/EFI/freebsd
cat > /Volumes/ESP/EFI/freebsd/loader.env <<'ENV'
console=efi,eficom
boot_serial=YES
boot_multicons=YES
loader_color=NO # optional — see note below
ENV
diskutil unmount /Volumes/ESP
hdiutil detach "${DEV%s*}"
loader.envgotchas: values are unquoted (console=efi, notconsole="efi"— the quotes are taken literally and you getno valid consoles!). Valid arm64 console names areefiandeficom; the oldcomconsolename is deprecated. Delete the AppleDouble junk macOS sprinkles on the FAT volume (dot_clean /Volumes/ESP) before unmounting.Washed-out / gray console background? The loader's boot menu paints the screen with ANSI black (
ESC[40m) for its color scheme, which clashes with a terminal whose background isn't pure black — it reads as a gray filter over the console.loader_color=NOdisables the loader's colors; the beastie logo and menu still render, just in your terminal's own colors.
SDKs
Drive bsdkrun from your own code. Each SDK is a thin, stateless wrapper around the
binary — it builds argv, shells out, and parses the JSON output. There is no daemon and no
long-lived state, so the SDKs are safe to use from short-lived processes and scripts.
| Language | Package | Source | Notes |
|---|---|---|---|
| TypeScript | @bsdkrun/sdk |
sdk/typescript |
Node / Deno / Bun. Adds a sh template tag, Terminal, and direct agent-protocol access. |
| Python | bsdkrun |
sdk/python |
No runtime dependencies. Developed with uv; type-checked under strict mypy. |
| Ruby | bsdkrun |
sdk/ruby |
No runtime dependencies. |
| Elixir | bsdkrun_ex |
sdk/elixir |
One dependency (:jason). {:ok, _} / {:error, _} with bang variants. Modules are plain Bsdkrun.*. |
| Gleam | bsdkrun |
sdk/gleam |
Erlang target. Fully typed Results; no exceptions. |
| Clojure | io.github.tsirysndr/bsdkrun |
sdk/clojure |
No runtime dependencies beyond org.clojure/clojure + data.json. A "sandbox" is a plain map — no object hierarchy. Docs on cljdoc.org. |
| Go | github.com/tsirysndr/bsdkrun/sdk/go |
sdk/go |
Zero third-party dependencies — stdlib only, hand-rolled graphql-transport-ws. Fluent builders ending in (T, error). |
| Rust | bsdkrun-sdk |
sdk/rust |
Blocking, no async runtime. Fluent consuming builders; standalone crate outside the workspace. |
Elixir publishes as bsdkrun_ex because Hex is a single namespace and the Gleam SDK
already takes bsdkrun there; its modules are unaffected.
All of them find the binary the same way — an explicit override, then $BSDKRUN_BIN, then
bsdkrun on $PATH, then an in-repo dev build — and expose the same surface: create /
exec / logs / lifecycle on a machine, plus the images, volumes, networks, and
system namespaces.
// TypeScript
const box = await Sandbox.create({ os: "linux", image: "alpine" });
await box.exec(["uname", "-a"]);# Python
box = Sandbox.create(os="linux", image="alpine")
box.exec(["uname", "-a"])# Ruby
box = Bsdkrun::Sandbox.create(os: "linux", image: "alpine")
box.exec(["uname", "-a"])# Elixir
{:ok, box} = Bsdkrun.create(os: :linux, image: "alpine")
{:ok, res} = Bsdkrun.exec(box, ["uname", "-a"])// Gleam
let assert Ok(box) = bsdkrun.create(args.linux("alpine"))
let assert Ok(res) = bsdkrun.exec(box, ["uname", "-a"]);; Clojure
(require '[bsdkrun.sandbox :as sandbox])
(def box (sandbox/create! {:os "linux" :image "alpine"}))
(sandbox/exec! box ["uname" "-a"])// Go
box, err := bsdkrun.Linux("alpine").Create()
res, err := box.Exec("uname", "-a")// Rust
let sandbox = Sandbox::linux("alpine").create()?;
let res = sandbox.exec(["uname", "-a"])?;Try it interactively
The REPL-native SDKs ship a console with the binary resolved and the API already in scope, so you can poke at real machines without writing a script:
cd sdk/python && uv run console.py # IPython
cd sdk/ruby && bin/console # IRB
cd sdk/elixir && iex -S mix # IEx, via .iex.exs
cd sdk/clojure && clj -M:rebel # rebel-readline, via dev/user.cljAll four define ps (every machine, exited ones included) and accept a
--bin path/to/bsdkrun override (BSDKRUN_BIN=… for IEx and rebel) to drive a locally built
binary.
See each SDK's README for the full API. The TypeScript SDK is covered by an end-to-end CI job that boots a real microVM under KVM; the others run unit + argv tests on every change.
Project layout
| Path | What it is |
|---|---|
src/krun.rs |
Safe Rust FFI bindings to the libkrun C ABI (krun_create_ctx, krun_set_vm_config, krun_add_disk, krun_set_kernel, krun_set_firmware, krun_set_root / krun_set_exec for virtio-fs rootfs, krun_add_net_unixgram for gvproxy networking, the krun_disable_implicit_console / krun_add_serial_console_default console wiring, krun_start_enter, …). Negative returns are decoded as -errno. |
src/main.rs |
clap CLI with the probe / kernel / firmware / linux boot subcommands and the ps / images / stop / logs / shell management subcommands. |
src/oci.rs |
Minimal OCI registry client: pulls a linux/arm64 image (any v2 registry) with curl, extracts layers with tar (applying whiteouts), and caches the rootfs content-addressed by digest. |
src/db.rs |
State persistence in SQLite (sqlx over a small Tokio runtime): machines, images, and disks, each with a short id; plus the state-dir layout. |
src/console.rs |
Detached-machine console broker: wires the guest console to a PTY and fans it out to console.log + a console.sock that logs/shell clients attach to. |
src/id.rs |
Docker-style short ids (12 hex chars from /dev/urandom). |
src/linux.rs |
The linux subcommand: fetches/converts the kernel, resolves the entrypoint, and builds the initramfs (generated /init) or wires the virtio-fs root. |
src/elf.rs |
Flattens an aarch64 vmlinux ELF into a raw arm64 Image (what libkrun's loader wants) — pure Rust, no binutils. |
src/net.rs |
User-mode networking: spawns and drives a per-VM gvproxy (unique host ssh-port, host→guest port forwards over its HTTP control socket), reaped on exit. |
src/tty.rs |
Saves stdin's terminal state before libkrun raws it and restores it on every exit path (clean, error, or signal); the signal handler also tears down gvproxy. |
src/watchdog.rs |
Tees libkrun's stderr to catch its HVF panic-hang on BSD SMP shutdown and exit cleanly. |
build.rs |
Finds libkrun via Homebrew and configures linking + rpath. |
Makefile |
Build and codesign (re-signs after every build — mandatory on macOS). |
bsdkrun.entitlements |
com.apple.security.hypervisor + library-validation opt-out. |
images/ |
Guest disk images and a symlink to libkrun's EDK2 firmware (git-ignored blobs). |
skills/ |
Agent skills published to skills.sh — skills/bsdkrun-cli/ documents every subcommand and flag for coding agents. |
sdk/ |
Client SDKs — TypeScript, Python, Ruby, Elixir, Gleam, Clojure, Go, and Rust. Each builds argv, shells out to the binary, and parses its JSON output. |
tools/console/ |
Contributor tooling: a Clojure/Babashka REPL centralizing every build/test/publish command in the monorepo. See Monorepo dev console. |
console |
Root shortcut: ./console == cd tools/console && clj -M:rebel. |
doc/cljdoc.edn |
cljdoc.org doc-tree config for the Clojure SDK (sdk/clojure), published to Clojars as io.github.tsirysndr/bsdkrun. |
Agent skill
The full CLI reference is packaged as an agent skill so
coding agents (Claude Code, Cursor, Codex, …) can drive bsdkrun correctly. Install it with:
npx skills add tsirysndr/bsdkrunIt lives in skills/bsdkrun-cli/: SKILL.md is the command map, and
references/cli-reference.md has the exhaustive flag list.
Troubleshooting
krun_start_enter failed: Invalid argument (errno 22)
The binary isn't signed with the hypervisor entitlement. Run make sign (or make build) and
try again. Remember a bare cargo build re-strips the signature.
Boot floods the terminal with rng: … Spurious event received
That's libkrun's own WARN-level logging, not the guest. Run with --log-level 0 to silence
libkrun's internal logs; the guest console still comes through. (At high verbosity this can
generate gigabytes of logs very quickly, so keep it low unless you're debugging libkrun itself.)
Segfault on boot (x16 == 0 NULL call in the crash report) — but probe works fine
DYLD is loading an old libkrun that predates the console symbols bsdkrun needs
(krun_add_serial_console_default / krun_disable_implicit_console); the missing symbol becomes a
NULL stub and calling it segfaults. probe survives because it never calls them. The usual cause
is a DYLD_LIBRARY_PATH in your shell rc pointing at a stale copy — commonly ~/.local/lib.
Diagnose and fix:
grep -ri DYLD ~/.zshrc ~/.zprofile ~/.profile # find the override
nm -gU ~/.local/lib/libkrun.1.dylib | grep serial_console # empty => too old
# quick check / workaround: run without the override
env -u DYLD_LIBRARY_PATH ./target/release/bsdkrun firmware --firmware images/KRUN_EFI.fd --disk images/fbsd15.rawPermanent fix: update or remove the stale ~/.local/lib/libkrun* so dyld falls through to
Homebrew's. Current bsdkrun detects this at runtime and prints an actionable error instead of
crashing — if you still get a raw segfault, rebuild (make build && make release).
Firmware boots but there's no guest console output
The guest is almost certainly booting — its output is just going to the PL011 serial that isn't
wired to your terminal. bsdkrun handles this automatically (see Console),
but if you're driving libkrun yourself you must disable the implicit console and add an explicit
serial console on fds 0/1. To see what the firmware is really doing, run at --log-level 5 and
decode the writes to krun_devices::legacy::aarch64::serial … write: offset=0, data=[…] — those
bytes are the guest console.
Crash on boot — epoll.rs: assertion left == right failed, left: -1 (SIGABRT/SIGSEGV)
libkrun tried to register a non-pollable console input fd (a regular file or /dev/null) with
kqueue. Current bsdkrun avoids this automatically (it only uses stdin when it's a TTY, else an
internal pipe — see Console), so if you hit this you're
on an old build: rebuild with make build. If you're calling libkrun yourself, never pass a
file//dev/null as the console input fd.
no valid consoles! from the FreeBSD loader
Your loader.env quoted the value (console="efi"). Use unquoted console=efi,eficom. See
Preparing a FreeBSD arm64 image.
Dynamic linker can't find libkrun.dylib
Make sure brew --prefix libkrun resolves, or build with an explicit
LIBKRUN_PREFIX=/opt/homebrew.
Guest hangs on poweroff/reboot with panicked at src/hvf/src/lib.rs:549: Unexpected val=…
A libkrun bug: on an SMP guest, shutting down issues PSCI CPU_OFF / AFFINITY_INFO calls
libkrun's HVF layer doesn't handle, so its vCPU threads panic and krun_start_enter never returns.
The guest has already halted cleanly. bsdkrun detects this and exits cleanly for you (a watchdog
tees libkrun's stderr and recognises the panic). To avoid the panic entirely, boot with --cpus 1.
Networking: gvproxy not found on PATH
Install it with brew install gvproxy (see Networking). Without it the guest boots
with no NIC; --no-net silences the warning.
Status
On macOS Apple Silicon, both guests boot to a login: prompt via the firmware subcommand,
through libkrun's EDK2 firmware and the guest's own EFI loader:
-
FreeBSD 15.1 / arm64 — full multi-user rc sequence to
login:.fetch+firmware. -
NetBSD-current / arm64 (evbarm
GENERIC64) — efiboot → kernel → root-on-ffs →login:.fetch --os netbsd+firmware. (NetBSD releases ≤ 10.1 boot but can't mount root — their virtio-mmio driver is legacy-only; modern v2 support is only in -current.)
On Linux/amd64 (KVM), both guests boot to multi-user via PVH direct kernel on the
PVH libkrun fork, validated on every
KVM e2e run (the in-guest agent answers exec):
-
NetBSD 10.1 / amd64 — bundled
MICROVMkernel + FFS rootfs,root=ld0a console=com. -
FreeBSD 15.1 / amd64 — bundled
FIRECRACKERkernel (no ACPI; MPTable; virtio-mmio + serial built in) + UFS rootfs. The fork supplies what the kernel can't discover on its own: an MPTable, the TSC frequency (CPUID leaf0x40000010), and FreeBSD's numberedvirtio_mmio.device_N=cmdline keys.
The blocker that made guests look dead — console output going to a serial port libkrun wasn't forwarding — is fixed by bsdkrun's serial-console wiring (see Console). Guest-side virtio-mmio device discovery under libkrun is confirmed working for FreeBSD and NetBSD on both platforms.
Recent: global networks with internal DNS (reach machines by name,
editable membership, cross-OS incl. NetBSD via synced /etc/hosts); data-preserving
stop/start (resumes the machine's own disk/rootfs, BSD clean-poweroff);
preconfigured flavors & snapshots; and a desktop
app (Machines / Images / Volumes / Flavors / Networks).
Next: upstreaming the PVH work to libkrun.
License
MIT © Tsiry Sandratraina