Run unikernels and sandboxed Linux containers on macOS with Apple Silicon, using the same OCI images and workflows as Linux.
hull is the microVM runtime brig drives on macOS, and it is useful on its own: it boots an OCI image as a real VM through Virtualization.framework, with a QEMU backend as an alternative.
Requires Apple Silicon (M1-M5). macOS 26 (Tahoe) is the recommended platform and the only one we test on; the Swift runner is built for macOS 14+, so earlier releases may well work, and the install does not block them. If you run one, tell us how it went.
brew tap brig-sh/brig
brew trust brig-sh/brig # brew refuses untrusted third-party taps
brew install --cask hullInstalling brig brings hull with it, since brig depends on it -- so
brew install --cask brig is the other way in.
The cask lands in the tap with hull's first stable release; until then, build
from source as described below. It installs hull and vz-runner side by
side (the CLI discovers the runner next to its own executable) and pulls in
e2fsprogs
for block-rootfs mode. The QEMU backend stays
optional: brew install qemu.
Check it works:
hull --helpBunny is a BuildKit frontend that
automatically packages any Dockerfile as a bootable unikernel image. It
bundles a Linux kernel and the urunit init process alongside your container
filesystem.
#syntax=harbor.nbfc.io/nubificus/bunny:latest
FROM ubuntu:24.04
RUN apt-get update && apt-get install -y nginx
CMD ["nginx", "-g", "daemon off;"]# Build and push
docker build -t ttl.sh/my-nginx:1h .
docker push ttl.sh/my-nginx:1h
# Run with Virtualization.framework
hull run --hypervisor vz --mem 512 --cpus 2 --net shared ttl.sh/my-nginx:1h
# Run with QEMU
hull run --hypervisor qemu --mem 512 --cpus 2 --net shared ttl.sh/my-nginx:1h# Run detached
ID=$(hull run -d --hypervisor vz --net shared ttl.sh/my-nginx:1h)
# List instances
hull ps
# View logs
hull logs $ID
hull logs -f $ID # follow
# Stop and remove
hull stop $ID
hull rm $IDhull supports two VMM backends:
| Backend | Technology | Network | Rootfs Modes | Boot Time |
|---|---|---|---|---|
| Vz | Apple Virtualization.framework | VZNATNetworkDeviceAttachment | virtiofs, ext4 block | ~60 ms |
| QEMU | QEMU + HVF acceleration | vmnet-shared | 9pfs, ext4 block | ~73 ms |
Both backends use hardware-accelerated virtualization (Apple Hypervisor Framework) and boot ARM64 Linux kernels with near-native performance.
A compose subcommand runs multi-service projects from a docker-compose
subset (docs/compose.md). How much of the compose spec is
covered β with per-capability status, divergences, and security tradeoffs β
is measured continuously and published in
Only needed to work on hull itself β to use it, install from the tap above.
- Apple Silicon (M1/M2/M3/M4/M5). macOS 26 (Tahoe) recommended and tested; the runner targets macOS 14+
- Go 1.26+ (see
go.mod) - Xcode Command Line Tools (includes Swift 5.9+)
The Vz backend needs the com.apple.security.virtualization entitlement. How
you get that entitlement honored depends on how the binary is signed:
-
Signed with a real Apple identity (recommended) β an Apple Development or Developer ID Application certificate. AMFI honors the entitlement with SIP enabled; no boot-arg changes are needed. This is the supported path (see Signing). Vz NAT networking (
--net shared) works with just this entitlement βcom.apple.vm.networkingis not required for NAT; it is only needed for bridged mode or QEMU'svmnetbackend. -
Ad-hoc signed (
codesign --sign -) β the entitlement is only honored when AMFI is disabled. This is a fallback for when you have no signing identity:# 1. Disable SIP: boot into Recovery Mode (hold Power button at startup), # open Terminal, run: csrutil disable # 2. Set AMFI boot arg (from normal macOS): sudo nvram boot-args="amfi_get_out_of_my_way=1" # 3. Reboot
This module consumes urunc as a Go
dependency. Until darwin support is merged upstream, go.mod replaces it with
the NOFireAI fork pinned at a pseudo-version of the darwin-integration
branch:
replace github.com/urunc-dev/urunc => github.com/nofireai/urunc v0.0.0-<date>-<sha>
When darwin-integration moves, refresh the pin:
PV=$(go list -m github.com/nofireai/urunc@darwin-integration | awk '{print $2}')
go mod edit -replace=github.com/urunc-dev/urunc=github.com/nofireai/urunc@"$PV"
go mod tidyFor local development against a live fork checkout, use an uncommitted
go.work (gitignored) instead of editing go.mod:
go 1.26.4
use (
.
/path/to/urunc # checkout on darwin-integration
)
Once darwin support lands upstream, drop the replace and require a released
github.com/urunc-dev/urunc version.
make app wraps both binaries into hull.app (urunc CNCF mark as the
app icon; vz-runner rides inside Contents/MacOS, so sibling discovery
works from /Applications). make dmg produces the drag-to-Applications
installer: styled background, NOFire volume icon, /Applications drop link,
signed + notarized + stapled. After dragging to Applications, put the CLI on
your PATH with:
ln -s /Applications/hull.app/Contents/MacOS/hull ~/.local/bin/hullPackaging assets live in packaging/ and are regenerated from the vendored
logo sources with scripts/make-packaging-assets.sh (needs imagemagick).
Regular CI (.github/workflows/ci.yml) lints and builds with ad-hoc
signatures only. Distributable builds come from the manually-invoked
Sign and Notarize workflow (.github/workflows/sign-notarize.yml):
gh workflow run sign-notarize.ymlIt needs five repository secrets:
| Secret | Content | How to produce |
|---|---|---|
MACOS_CERT_P12 |
base64 of the Developer ID Application .p12 (cert + private key) |
base64 -i DeveloperID.p12 β export from Keychain Access β My Certificates |
MACOS_CERT_PASSWORD |
password protecting the .p12 |
chosen at export time |
NOTARY_KEY_P8 |
App Store Connect API private key, plain .p8 contents |
App Store Connect β Users and Access β Integrations β App Store Connect API |
NOTARY_KEY_ID |
the API key's ID | shown next to the key |
NOTARY_ISSUER_ID |
the issuer UUID | shown on the same page |
Set them with:
gh secret set MACOS_CERT_P12 --repo brig-sh/hull < cert.p12.b64
gh secret set MACOS_CERT_PASSWORD --repo brig-sh/hull
gh secret set NOTARY_KEY_P8 --repo brig-sh/hull < AuthKey_XXXX.p8
gh secret set NOTARY_KEY_ID --repo brig-sh/hull
gh secret set NOTARY_ISSUER_ID --repo brig-sh/hullhull consists of three binaries:
| Binary | Language | Description |
|---|---|---|
hull |
Go | CLI for pulling OCI images and orchestrating VM lifecycle |
vz-runner |
Swift | Virtualization.framework backend (launches VZVirtualMachine) |
containerd-shim-urunc-v2 |
Go | containerd shim (for containerd integration) |
brew install go qemu e2fsprogshvi, the Hypervisor Virtualization Interface, is a submodule and is built by
make macos, so
clone with --recursive (or run git submodule update --init afterwards).
Building it needs a Rust toolchain, which rustup installs at the pinned
version named in hvi-vmm/rust-toolchain.toml.
# Build hull + vz-runner + hvi and sign all three with your Apple signing
# identity. Find the identity with: security find-identity -v -p codesigning
make macos CODESIGN_IDENTITY="Apple Development: Your Name (TEAMID)"
# Verify what got signed (authority chain + entitlements)
make codesign_verify
# Symlink into the current directory (vz-runner must sit next to hull)
ln -sf dist/hull_arm64 ./hull
ln -sf cmd/vz-runner/.build/arm64-apple-macosx/release/vz-runner ./vz-runnerOmit CODESIGN_IDENTITY to fall back to ad-hoc signing (-), which requires a
disabled AMFI (see System Configuration).
make sign (invoked by make macos) signs vz-runner with the
virtualization-only entitlements plist (cmd/vz-runner/Entitlements-novmnet.plist),
signs hvi with com.apple.security.hypervisor (hvi-vmm/hvi.entitlements),
signs hull, and strips the quarantine attribute from all three.
hvi talks to Hypervisor.framework directly rather than through
Virtualization.framework, so it needs the hypervisor entitlement and not the
virtualization one. The same rule applies to both: a real Apple identity for
the entitlement to be honored under SIP, ad-hoc only with AMFI disabled.
Keychain prerequisite. Signing with an Apple identity needs the full trust
chain in your keychain: your leaf certificate plus the Apple Worldwide
Developer Relations intermediate and the Apple Root CA. If codesign fails
with unable to build chain to self-signed root / errSecInternalComponent,
the intermediates are missing β install them once:
curl -fsSLO https://www.apple.com/certificateauthority/AppleWWDRCAG3.cer
curl -fsSLO https://www.apple.com/appleca/AppleIncRootCertificate.cer
security import AppleWWDRCAG3.cer -k ~/Library/Keychains/login.keychain-db
security import AppleIncRootCertificate.cer -k ~/Library/Keychains/login.keychain-db
security find-identity -v -p codesigning # should now list a valid identityQuarantine ("Open Anyway" prompts). If binaries were transferred via
AirDrop, a download, or file sharing, macOS tags them with
com.apple.quarantine and Gatekeeper prompts you to "Open Anyway". Locally
built binaries are not quarantined. make sign strips it; to do it by hand:
xattr -dr com.apple.quarantine ./hull ./vz-runnerAn Apple Development certificate is enough for local use once quarantine is stripped. To let other machines run the binaries without prompts, sign with a Developer ID Application certificate and notarize (see Distribution).
Or build each component individually:
# Using make
make urunc_macos
# Output: dist/hull_arm64
# Or directly with go build
go build -o hull ./cmd/hull/# Build release binary
cd cmd/vz-runner
swift build -c release
cd ../..
# The binary is at:
# cmd/vz-runner/.build/arm64-apple-macosx/release/vz-runnerImportant: vz-runner must be re-signed with entitlements every time it is
built or copied. Without the entitlement, macOS rejects the virtualization API
at runtime. Prefer make sign (see Signing); to sign by hand:
codesign --force --options runtime \
--sign "Apple Development: Your Name (TEAMID)" \
--entitlements cmd/vz-runner/Entitlements-novmnet.plist \
cmd/vz-runner/.build/arm64-apple-macosx/release/vz-runnerThe default plist (cmd/vz-runner/Entitlements-novmnet.plist) contains only:
com.apple.security.virtualizationβ required to create VZVirtualMachine
This is sufficient for the Vz backend, including NAT networking
(VZNATNetworkDeviceAttachment, --net shared). com.apple.vm.networking is
not needed for NAT; the older Entitlements.plist (which adds it) is only
relevant for bridged networking.
Only needed if integrating with containerd on macOS:
go build -o containerd-shim-urunc-v2 ./cmd/containerd-shim-urunc-v2/Install QEMU >= 7.2 (brew install qemu) β nothing else. Under compose
(docs/compose.md) or any run --gateway-sock ...
invocation, QEMU networking goes through the
user-mode gateway over a unix-socket stream netdev: no vmnet, no
com.apple.vm.networking entitlement, no root, no re-signed binary, and
HVF acceleration works out of the box (com.apple.security.hypervisor is
not a restricted entitlement).
The legacy vmnet path below applies only to standalone
run --hypervisor qemu --net shared without a gateway; prefer the gateway.
Legacy: re-sign QEMU for direct vmnet networking
The Homebrew QEMU binary does not have the com.apple.vm.networking
entitlement, so vmnet-shared networking will fail. To fix this, copy and
re-sign the binary:
# Copy QEMU binary
cp $(which qemu-system-aarch64) /usr/local/bin/qemu-system-aarch64-signed
# Create entitlements and sign
codesign --force --sign - --entitlements <(cat <<'EOF'
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>com.apple.security.hypervisor</key><true/>
<key>com.apple.vm.networking</key><true/>
</dict></plist>
EOF
) /usr/local/bin/qemu-system-aarch64-signedThen configure urunc to use the signed binary (see urunc configuration).
Place the binaries where hull can find them. vz-runner must be in
the same directory as hull (it is located via os.Executable()):
# Option A: install to /usr/local/bin
sudo cp dist/hull_arm64 /usr/local/bin/hull
sudo cp cmd/vz-runner/.build/arm64-apple-macosx/release/vz-runner /usr/local/bin/vz-runner
# Re-sign vz-runner after copying (entitlements are stripped on copy)
sudo codesign --force --sign - \
--entitlements cmd/vz-runner/Entitlements.plist \
/usr/local/bin/vz-runner
# Option B: run from the build directory
ln -sf dist/hull_arm64 ./hull
ln -sf cmd/vz-runner/.build/arm64-apple-macosx/release/vz-runner ./vz-runner# Check hull
./hull --help
# Check vz-runner entitlements
codesign -d --entitlements :- ./vz-runner 2>/dev/null | grep -c virtualization
# Should print: 1
# Check QEMU (if using QEMU backend)
qemu-system-aarch64 --versionhull looks for the QEMU binary in a config file or uses the system
default. To point it at a signed QEMU binary, create ~/.hull/config.json:
{
"monitors": {
"qemu": {
"path": "/usr/local/bin/qemu-system-aarch64-signed"
}
}
}The Vz backend uses Apple's native Virtualization.framework via a Swift helper
binary (vz-runner). It provides the best network throughput and tightest
macOS integration.
./hull run --hypervisor vz --mem 2048 --cpus 4 --net shared <image>How it works:
hullprepares the rootfs and kernel command line- Launches
vz-runnerwith--kernel,--cmdline,--share(virtiofs) or--rootfs(block) vz-runnercreates aVZVirtualMachine, attaches serial console to stdin/stdout- Guest kernel boots with
root=rootfs rootfstype=virtiofs(virtiofs mode) orroot=/dev/vda(block mode)
Requirements:
vz-runnermust be signed with thecom.apple.security.virtualizationentitlement (NAT networking needs nothing more; see Signing)- macOS 26+ (VZLinuxBootLoader requires ARM64 Image format kernel, not PE32+/EFI stub)
An ordinary image such as ubuntu:latest can use a host ARM64 Linux Image
and the generic container initrd while its unpacked rootfs is shared directly
over Vz virtiofs. Hull exports that image directory read-only; /vz-init in
the initrd mounts it as the lower layer of an in-guest tmpfs overlay, restores
the OCI argv/environment, and switches root without modifying the image.
./hull run --hypervisor vz --rootfs-type virtiofs \
--annotation com.urunc.unikernel.bootKernel=/host/arm64/Image \
--annotation com.urunc.unikernel.bootInitrd=/host/container-initrd \
ubuntu:latest /bin/echo hello-from-vzThe two annotations are optional. When the image carries no kernel of its own
and the backend is vz or hvi, hull resolves them itself from
<store-dir>/assets (so ~/.hull/store/assets by default), downloading the
published bundle if it is not there yet:
./hull run --hypervisor vz ubuntu:latest /bin/echo hello-from-vzThe bundles are built and published by
NOFireAI/hull-assets, one OCI
artifact per host platform. hull assets show says where they are and what is
present; hull assets pull [REF] fetches them ahead of time. --no-boot-assets
turns the fallback off, and HULL_BOOT_ASSETS points at a local build of the
assets repo instead. The assets live under the store so that --store-dir is a
complete isolation boundary: two stores never share a kernel, and a hull assets pull in one shell cannot change what a run using another store boots.
HULL_BOOT_ASSETS_REF overrides the reference, which is
how you pin a version.
| Variable | What it does |
|---|---|
HULL_BOOT_ASSETS |
Use this directory instead of the store's, for a local build of the assets repo |
HULL_BOOT_ASSETS_REF |
Fetch this reference instead of the one for this platform, to pin a version or use a mirror |
HULL_REGISTRY_TOKEN |
A token with read:packages, for a private bundle or a machine whose keychain cannot be unlocked -- a CI runner, or any headless session, where Docker's credential helper cannot prompt |
Passing the annotations explicitly still wins, so nothing that already sets them changes behaviour -- brig, in particular, keeps supplying its own.
The same generic boot path on HVI uses a persistent writable root without a block image or a RAM-backed upper layer. Before boot, Hull replaces the instance bundle's image-cache symlink with a same-volume APFS copy-on-write clone, restores the source hard-link graph, and exports that private directory read-write. Changes remain in that instance directory while the cached OCI rootfs stays unchanged. The image store and instance store must therefore be on the same APFS volume.
./hull run --hypervisor hvi --rootfs-type virtiofs \
--annotation com.urunc.unikernel.bootKernel=/host/arm64/Image \
--annotation com.urunc.unikernel.bootInitrd=/host/container-initrd \
ubuntu:latest /bin/echo hello-from-hviThe QEMU backend uses qemu-system-aarch64 with -accel hvf for
hardware-accelerated virtualization.
./hull run --hypervisor qemu --mem 2048 --cpus 4 --net shared <image>How it works:
hullbuilds the QEMU command line with HVF, vmnet-shared, and 9pfs/block device- Guest kernel boots with
root=rootfs rootfstype=9p rootflags=trans=virtio,version=9p2000.L(9pfs mode) orroot=/dev/vda(block mode)
Requirements:
brew install qemu- For networking: QEMU binary must be signed with
com.apple.vm.networkingentitlement:cp $(which qemu-system-aarch64) /usr/local/bin/qemu-aarch64-signed codesign --force --sign - --entitlements <(cat <<EOF <?xml version="1.0" encoding="UTF-8"?> <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd"> <plist version="1.0"><dict> <key>com.apple.security.hypervisor</key><true/> <key>com.apple.vm.networking</key><true/> </dict></plist> EOF ) /usr/local/bin/qemu-aarch64-signed
The container rootfs directory is shared into the guest via virtiofs. An overlayfs layer with a tmpfs upper dir is mounted on top to handle writes and work around macOS virtiofs limitations (mode-000 files).
./hull run --hypervisor vz <image>The container rootfs directory is shared via QEMU's built-in 9p filesystem
with security_model=none. No overlay needed.
./hull run --hypervisor qemu <image>Creates an ext4 disk image from the container rootfs using mke2fs -d and
attaches it as a virtio block device. This provides a native POSIX filesystem
without the limitations of virtiofs or 9pfs.
# Works with both backends
./hull run --hypervisor vz --rootfs-type block <image>
./hull run --hypervisor qemu --rootfs-type block <image>Requires: brew install e2fsprogs
hull [global flags] <command> [flags] [args]
Global Flags:
--debug Enable debug logging
--store-dir Images, instances and boot assets (default: ~/.hull/store)
--unattended Skip the telemetry consent prompt
--dnt Record a telemetry opt-out
Commands:
pull Pull an OCI image
run Create and run a unikernel
exec Run a command in a running instance
ps List running instances
stop Stop a running instance
checkpoint Checkpoint a running Vz instance
restore Restore a stopped Vz instance from its checkpoint
rm Remove a stopped instance
logs View instance logs
inspect Inspect instance details
images List pulled images
assets Manage the boot assets for images that carry no kernel
store Manage the volume the store lives on
compose Run multi-service compose files
telemetry Control anonymous usage and crash telemetry
--store-dir is where images, instances and boot assets live, and it is a
complete isolation boundary: two stores never share so much as a kernel.
It is also a case-sensitive APFS volume, which hull creates and mounts the
first time a command opens it. That is not decoration. A Linux package tree
contains names that differ only by case -- xt_CONNMARK.h and xt_connmark.h
sit in the same directory -- and unpacking one where they collide gives a guest
that boots and then goes nowhere. hull refuses to use a case-insensitive store
rather than let that happen, and will not mount over a directory that already
has something in it.
hull store detach # give the mount back; the image and its contents stayThere is no attach. Anything that opens the store mounts it, so the next
command brings it back with everything still in it. One consequence worth
knowing: with the store detached, hull assets show reports the assets as
missing, because it only reads a path and will not mount a volume to answer a
question. hull assets pull remounts and finds them already there.
hull assets show # where the assets are, and whether they are present
hull assets dir # just the directory, for scripts
hull assets pull [REF] # fetch them ahead of time| Flag | Default | Description |
|---|---|---|
--hypervisor |
from image | vz or qemu |
--rootfs-type |
auto | virtiofs, 9pfs, or block |
--mem |
512 | Memory in MB |
--cpus |
1 | Number of vCPUs |
--net |
none | none or shared (NAT) |
--detach, -d |
false | Run in background |
--name |
random | Instance name |
--shared-dir |
β | Host path to share: /host:/guest |
Both backends provide NAT networking with automatic DHCP:
| Vz | QEMU | |
|---|---|---|
| Subnet | 192.168.64.x | 192.168.64.x (vmnet) |
| Gateway | 192.168.64.1 | 192.168.64.1 |
| DHCP | Automatic (ip=dhcp) |
Automatic (ip=dhcp) |
| DNS | Copies from /proc/net/pnp |
Copies from /proc/net/pnp |
Guest DNS resolution is configured automatically by the init wrapper,
which copies the kernel DHCP response from /proc/net/pnp to /etc/resolv.conf.
Benchmarks on Mac Studio (Mac14,13), macOS 26.3.1, 2 vCPUs, 2048 MB RAM:
| Test | Vz | QEMU+HVF | Winner |
|---|---|---|---|
| TCP TX (guestβhost) | 25,043 Mbps | 3,057 Mbps | Vz 8.2x |
| TCP RX (hostβguest) | 55,179 Mbps | 9,373 Mbps | Vz 5.9x |
| Ping latency | 0.29 ms | 0.28 ms | Tie |
| Test | Vz | QEMU+HVF | Winner |
|---|---|---|---|
| Sequential Write 1M | 15,419 MB/s | 10,631 MB/s | Vz 1.45x |
| Random Write 4K | 4,394 MB/s | 3,118 MB/s | Vz 1.41x |
| Random Read 4K | 9 MB/s | 23 MB/s | QEMU 2.5x |
| Metric | Vz | QEMU+HVF |
|---|---|---|
| Kernel to init | ~60 ms | ~73 ms |
| DHCP complete | ~70 ms | ~164 ms |
Summary: Vz is the recommended backend for most workloads β it provides dramatically better network throughput (8x TX) and faster writes, with sub-100ms boot times.
ββββββββββββββββββββββββββββββββββββββββββββββββββββ
β hull CLI (Go) β
β Pull OCI image β prepare rootfs β build cmdline β
ββββββββββββ¬ββββββββββββββββββββββββ¬ββββββββββββββββ
β β
βββββββΌββββββ βββββββΌβββββββ
β vz-runner β β QEMU β
β (Swift) β β aarch64 β
β Vz.fwk β β +HVF β
βββββββ¬βββββββ βββββββ¬βββββββ
β β
βββββββΌββββββββββββββββββββββββΌβββββββ
β Apple Hypervisor Framework (HVF) β
βββββββ¬βββββββββββββββββββββββββββββββ
β
βββββββΌβββββββββββββββββββββββββββββββ
β ARM64 Linux Kernel (6.18.0urunc) β
β βββ virtiofs / 9pfs / ext4 root β
β βββ .vz-init / .qemu-init / ... β
β βββ urunit β user process β
ββββββββββββββββββββββββββββββββββββββ
hull injects a small shell script as init= to set up the guest
environment before handing off to urunit (or the user's entrypoint):
| Wrapper | Backend | What it does |
|---|---|---|
/vz-init in the generic initrd |
Vz + generic OCI image | read-only virtiofs lower β tmpfs overlay β OCI metadata β switch_root β entrypoint |
.vz-init |
Vz + virtiofs | overlayfs (tmpfs upper) β pivot_root β devtmpfs β devpts β resolv.conf β exec urunit |
.qemu-init |
QEMU + 9pfs | devtmpfs β devpts β tmpfs /tmp β resolv.conf β exec urunit |
.block-init |
Any + ext4 | devtmpfs β devpts β tmpfs /tmp β resolv.conf β exec urunit |
Bunny-built images store configuration in rootfs/urunc.json with base64-encoded values:
| Annotation | Description |
|---|---|
com.urunc.unikernel.binary |
Path to kernel (e.g., /.boot/kernel) |
com.urunc.unikernel.hypervisor |
Default backend (qemu or vz) |
com.urunc.unikernel.unikernelType |
Unikernel type (e.g., linux) |
com.urunc.unikernel.cmdline |
Custom kernel command line |
com.urunc.unikernel.mountRootfs |
true to use virtiofs/9pfs rootfs mode |
com.urunc.unikernel.bootKernel |
Host ARM64 Linux Image used to boot an unmodified OCI image |
com.urunc.unikernel.bootInitrd |
Host generic initrd containing /vz-init; paired with bootKernel |
Runtime annotations can be supplied without rebuilding the image using the
repeatable hull run --annotation KEY=VALUE option.
The last two are filled in automatically when the image carries no kernel and the backend can boot it generically -- see Booting an unmodified OCI image.
Re-sign vz-runner with the entitlements plist (with SIP enabled you must use a real Apple signing identity, not ad-hoc β see Signing):
make sign CODESIGN_IDENTITY="Apple Development: Your Name (TEAMID)"The Apple WWDR intermediate and/or Apple Root CA are missing from your keychain. Install them once β see Signing β Keychain prerequisite.
The binary carries a com.apple.quarantine attribute (set when it was
AirDropped, downloaded, or file-shared). Strip it:
xattr -dr com.apple.quarantine ./hull ./vz-runnermake sign does this automatically. Locally built binaries are never
quarantined.
Foreground run makes vz-runner's controlling terminal your stdin, which fails
(ENODEV) when stdin is not a real TTY (e.g. run from a script, pipe, or nohup).
Use --detach and read output with hull logs, or run from an
interactive terminal.
This is QEMU only β the vmnet framework requires the caller to be root or
hold com.apple.vm.networking. Options, best first:
- Use the Vz backend (
--hypervisor vz --net shared) β NAT works with just the virtualization entitlement, no root, and is faster. - socket_vmnet β a small root launchd daemon owns the interface and hands unprivileged QEMU a socket.
- Run QEMU as root (
sudo) β vmnet shared mode is allowed for root. - Sign QEMU with
com.apple.vm.networkingβ requires Apple to grant the managed entitlement to your team plus a provisioning profile; not available with a plain Apple Development certificate.
The kernel is in PE32+/EFI stub format. VZLinuxBootLoader requires the
uncompressed ARM64 Image format (magic ARMd at offset 0x38). Bunny
images include a compatible kernel automatically.
Check the rootfs type matches the kernel command line:
- virtiofs: needs
root=rootfs rootfstype=virtiofsand virtiofs built into the kernel - 9pfs: needs
root=rootfs rootfstype=9p rootflags=trans=virtio,version=9p2000.Land 9p built into the kernel - block: needs
root=/dev/vda rwand ext4+virtio-blk built into the kernel
Normal β the guest serial console is not a real TTY. Interactive shells work
but job control (Ctrl-Z, fg, bg) is not available.
An Apple Development certificate is fine for building and running on your own machine (once quarantine is stripped). To distribute binaries that run on other Macs without Gatekeeper prompts, you must sign with a Developer ID Application certificate and notarize:
# Sign with Developer ID (hardened runtime + timestamp) β same entitlements plist
make sign CODESIGN_IDENTITY="Developer ID Application: Your Name (TEAMID)"
# Notarize (needs an app-specific password or an API key stored as a keychain profile)
xcrun notarytool store-credentials urunc-notary \
--apple-id you@example.com --team-id TEAMID --password <app-specific-password>
ditto -c -k --keepParent ./vz-runner vz-runner.zip
xcrun notarytool submit vz-runner.zip --keychain-profile urunc-notary --wait
# Staple the ticket so it verifies offline
xcrun stapler staple ./vz-runnerCLI binaries (not .app bundles) can't have a stapled ticket embedded the same
way an app can, but notarization still registers the code with Apple so
Gatekeeper passes. spctl -a -vv -t exec ./vz-runner should report accepted
after notarization; with a plain Apple Development cert it reports rejected,
which is expected and harmless for local use.
Developer ID certificates require membership in the Apple Developer Program ($99/year) and are only issued to the Account Holder (or an Admin) of the team β not available on a free account.
-
Enroll at https://developer.apple.com/programs/ if you haven't.
-
Create the certificate, either way:
- Xcode: Settings β Accounts β select your team β Manage Certificatesβ¦ β + β Developer ID Application. Xcode creates the private key and installs the cert into your login keychain.
- Developer portal: https://developer.apple.com/account/resources/certificates/list
β + β Developer ID Application. Generate a Certificate Signing Request
first via Keychain Access β Certificate Assistant β Request a Certificate
From a Certificate Authority (save to disk), upload the CSR, then download
and double-click the resulting
.certo import it.
-
Verify it's usable for signing:
security find-identity -v -p codesigning # look for "Developer ID Application: β¦"As with Development certs, the Apple WWDR intermediate and Apple Root CA must be in your keychain (see Signing β Keychain prerequisite).
- Bump the
VERSIONfile (the tag must match it), commit, and tag:git tag v$(cat VERSION) && git push origin v$(cat VERSION). - The Sign and Notarize workflow runs on the tag: it builds, signs with
Developer ID, notarizes, and creates the GitHub release with the dmg and
the Homebrew tarball (
hull-<version>-arm64.tar.gz+ sha256). - Update
Formula/hull.rbin the tap with the new version URL and the sha256 printed in the workflow's job summary.
hull is based on urunc, a CNCF Sandbox project. urunc does the hard part -- it runs unikernels and lightweight VMs as OCI containers -- and hull carries that onto macOS, on top of Virtualization.framework.
hull is not a CNCF project and is not endorsed by the CNCF. The Linux Foundation has registered trademarks and uses trademarks. For a list of trademarks of The Linux Foundation, please see our Trademark Usage page. urunc, CNCF and the CNCF logo are trademarks of The Linux Foundation.
Powered by NOFire AI