Virtualization Backends

macOS Backends

macOS ships two very different sandbox backends. They are not interchangeable — they differ in isolation, in how you reach a service inside them, and in which lifecycle commands work at all. See macOS Capabilities for the full matrix.

Short version: apple_container is the platform default and is the lighter, faster option for running commands. Choose apple_virt if you need SSH, heyvm port-forward, or to reach a service from another machine.

apple_virt

Native Apple Virtualization.framework virtual machines, via the avfbind crate. Runs on Apple Silicon and requires the com.apple.security.virtualization entitlement (handled by make sign — see Installing From Source). Guests boot via EFI + GRUB.

Images are auto-downloaded from S3: alpine-3.21 (the default, also aliased alpine and default), ubuntu-24.04, and the -rust, -node, and -elixir variants. To verify the backend end-to-end on a new machine, run heyvm test-apple-virt — it creates a VM, confirms SSH and exec, and cleans up.

Each guest gets a host-routable NAT IP, so you reach a service inside it directly — no port mapping step:

heyvm get <id>                    # read the guest IP
curl http://<guest-ip>:5000       # the service must bind 0.0.0.0, not 127.0.0.1

--open-port does not apply here and is ignored with a warning; the guest IP is the supported path. heyvm port-forward <id> <port> also works, tunnelling over SSH to 127.0.0.1 on the host.

Images are built by the scripts in mvm-ctrl/install/ — use build-apple-virt-image.sh for the Alpine base and build-apple-virt-ubuntu-images.sh for the Ubuntu base and its toolchain variants. Both need Docker.

Backend name: apple_virt.

Building a custom image

heyvm images build --backend apple_virt turns a Dockerfile into a bootable apple_virt image. It runs docker build --platform linux/arm64, exports the container filesystem, and packs it into an ext4 rootfs inside a privileged Alpine helper container — so you do not need mke2fs on the host, which macOS does not ship.

heyvm images build --backend apple_virt \
  --file    ./Dockerfile \
  --context . \
  --name    my-image \
  --size-mb 16384 \
  --from    ubuntu-24.04

The result is a directory at ~/.heyo/images/apple_virt/my-image/ containing rootfs.img, vmlinuz, initrd.img, and grubaa64.efi. Boot it with heyvm create --backend apple_virt --image my-image.

heyvm mvm build cannot produce an apple_virt image. It targets Firecracker: it requires a host mke2fs, it emits a single .ext4 file rather than an image directory, and the rootfs it produces contains no kernel — Firecracker supplies one from the host, whereas apple_virt boots through EFI + GRUB and needs the kernel inside the rootfs.

Four things that are easy to get wrong

Pass --from explicitly. The donor image supplies the vmlinuz, initrd.img, and grubaa64.efi that ship alongside your rootfs. It defaults to default, but the builder resolves that name literally while the runtime aliases default to alpine-3.21 — so on a machine that only ever downloaded the canonical name, a bare build fails with a missing-donor error. Match the donor to your base distro; an Alpine kernel against a glibc userland will not boot cleanly.

docker export discards the image config. Only the filesystem is copied into the rootfs, so every ENV, WORKDIR, USER, CMD, and ENTRYPOINT in your Dockerfile is build-time only and vanishes in the VM. Anything the guest needs at runtime has to land in a file — /etc/environment (read by PAM, so ssh picks it up), /etc/profile.d/ (login and interactive shells), or a symlink in /usr/local/bin (non-login sh -c execs, which read neither).

systemctl enable does not work during a build, because there is no live systemd. Write the wants symlinks yourself:

ln -sf /lib/systemd/system/redis-server.service \
  /etc/systemd/system/multi-user.target.wants/redis-server.service

Re-stamp /boot as the very last step. The host writes a fixed grub.cfg onto each per-sandbox EFI partition that boots /boot/vmlinuz-lts and /boot/initramfs-lts from the rootfs. Any layer that pulls a kernel update or triggers update-initramfs — installing PostgreSQL can — invalidates those copies. On arm64, Ubuntu ships a gzip-compressed kernel that GRUB needs decompressed here:

VMLINUZ=$(ls /boot/vmlinuz-*-generic | head -1)
INITRD=$(ls /boot/initrd.img-*-generic | head -1)
gzip -t "$VMLINUZ" 2>/dev/null && gzip -dc "$VMLINUZ" > /boot/vmlinuz-lts \
                               || cp -f "$VMLINUZ" /boot/vmlinuz-lts
cp -f "$INITRD" /boot/initramfs-lts

If the rootfs has no /boot/vmlinuz-lts at all, the packer injects the donor's kernel instead. That only boots if your guest's drivers are already built into that kernel, so installing a kernel package in your own Dockerfile is the reliable path.

Finally, --size-mb sizes the whole rootfs, not a delta over the base — it has to cover the base image, everything you install, and the work you plan to do inside. The build refuses to start if the exported filesystem already exceeds it.

Worked example: Rails + Vite

mvm-ctrl/examples/apple-virt-rails-vite/ is a complete, runnable example: an Ubuntu 24.04 arm64 image with Ruby compiled from source via mise, Node and npm, PostgreSQL 16 and Redis running in-VM, and the native library set Rails actually needs — libvips, ImageMagick, libheif with its de265/aom/x265 codec plugins, qpdf, poppler-utils, libpq, libxml2/libxslt.

cd mvm-ctrl
./examples/apple-virt-rails-vite/build.sh --create
heyvm sh rails-vite

It derives FROM heyvm-apple-virt-ubuntu-24.04:base — a local Docker tag, not a registry image. The build script materializes it by loop-mounting the cached ~/.heyo/images/apple_virt/ubuntu-24.04/rootfs.img and docker importing it, which is what install/build-apple-virt-ubuntu-images.sh does for the shipped toolchain images. heyvm passes --pull=false to docker build, so the tag is never looked up remotely.

Two details from that example generalize:

  • Split-out codec packages. Ubuntu ships libheif's codecs as separate libheif-plugin-* packages. Install only libheif1 and HEIC decoding fails at runtime with no build-time warning.
  • First-boot units for state you cannot create at build time. The example ships a oneshot unit, guarded by ConditionPathExists, that creates a PostgreSQL superuser and the app databases on first boot. postgresql.service is Type=oneshot and returns before the cluster accepts connections, so the unit polls for readiness rather than relying on After= alone.

apple_container

Uses Apple's container CLI tool to run Linux guests as lightweight VMs. This is the default backend on macOS.

Ports are mapped to the host at creation time, and only at creation time:

heyvm create --name my-sandbox --open-port 18734:5000
curl http://127.0.0.1:18734       # reaches the guest's :5000

This is same-machine only. There is no SSH server in the guest, so heyvm port-forward is not available on this backend, and snapshot, checkpoint, and fork do not work — see macOS Capabilities. If you need any of those, or need to reach the sandbox from another machine, use apple_virt.

Backend name: apple_container (aliases apple-container, apple_vf, apple-vf; API JSON additionally accepts applevf, which is not valid on the CLI or in MVM_BACKENDS).

Note the alias collision: apple_vf maps to the Apple Container backend, while apple_virt is the native Virtualization.framework backend.

sandbox_exec

Native macOS process sandboxing via sandbox-exec. No VM — it confines a process with a sandbox profile. Lightweight, with weaker isolation than a VM.

Backend name: sandbox_exec.