The shape of it
A Virtualization.framework VM has one build order that everything else hangs off: declare a VZVirtualMachineConfiguration — CPU count, memory, a boot loader, and a list of devices — validate it, then hand it to a VZVirtualMachine to actually run. Nothing starts until that whole configuration object exists; there's no incremental "add a device while it's running" for anything declared this way. The rest of this post is what actually goes into that device list, using Velo Workspaces' own configuration code as the concrete example rather than a stub.
Boot loaders: two real options, and why one of them wins for normal guests
Apple gives you two ways to boot a Linux guest: VZLinuxBootLoader, which boots a kernel and initrd file directly and skips a boot menu entirely, and VZEFIBootLoader, which boots the way real hardware does — through EFI firmware, with a persisted VZEFIVariableStore holding boot order and other NVRAM variables across reboots. Velo Workspaces uses the EFI path for every Linux guest, verified in its own configuration code: the variable store is created once per workspace and reloaded on every subsequent boot, exactly like a real machine's NVRAM. The practical reason to prefer EFI over direct kernel boot: it's what lets an official distro installer ISO boot and present a normal install experience — GRUB menu and all — instead of requiring you to already have a kernel and initrd extracted before the VM can start.
Storage isn't one device type — it's three roles
A guest's disks aren't interchangeable in Virtualization.framework; the device type signals intent:
VZVirtioBlockDeviceConfigurationwraps aVZDiskImageStorageDeviceAttachmentfor the guest's main, read-write disk.VZUSBMassStorageDeviceConfiguration, also wrapping a disk-image attachment but read-only, is how an installer ISO or CD-ROM gets attached — as virtual USB media, not a second block device.
The attachment itself has two settings worth knowing about: cachingMode and synchronizationMode. Velo's own main-disk attachment uses .cached caching deliberately — the default (.automatic) resolves to effectively uncached in practice, which is fine for a long-lived server workload but costly for what this app spends most of its time on: an OS installer unpacking gigabytes of small files, rewriting the same metadata blocks over and over. Caching absorbs that. synchronizationMode is left at its default, .full, deliberately — that's what keeps the guest's own flushes durable, so an abrupt host power loss costs no more than it did without caching; only writes the guest hadn't yet asked to be durable are at risk. It's a concrete illustration of why these two settings aren't just performance knobs — they trade against a real durability guarantee, and the right default depends on the workload, not just "faster is better."
Networking: NAT by default, and a MAC address that isn't random
VZVirtioNetworkDeviceConfiguration paired with a VZNATNetworkDeviceAttachment gives a guest shared (NAT) networking out of the box — the guest gets its own address behind the host's, with no bridged visibility onto the host's LAN. Bridged networking is a separate, harder-won thing: it requires the com.apple.vm.networking entitlement, which is its own approval step with Apple, not just a configuration flag — a real constraint worth knowing about before assuming bridged mode is a five-minute addition.
One detail worth calling out because it solves a real problem: Velo derives each guest's MAC address deterministically from the workspace's own UUID rather than letting the framework randomize it. VZNetworkDevice doesn't expose its live MAC address back to the host app, so a randomized address would make it impossible to reliably find a specific guest's IP in the host's ARP table after a restart. A stable, derived MAC means the guest keeps the same DHCP lease across reboots, and the host can always find it. See how Linux VM networking actually works for the three different paths this NAT setup produces in practice, including why reaching a guest from another device on your LAN needs an explicit port forward.
Graphics: three device types, and only one of them supports multiple monitors
This is a deeper layer than the "no GPU passthrough for Linux" point covered in the fundamentals — it's about what each guest OS gets for a display, not compute:
| Guest | Device | What it actually gets |
|---|---|---|
| macOS | VZMacGraphicsDeviceConfiguration | Paravirtualized Metal pipeline, near-native GPU performance, up to 4 independent displays |
| Linux | VZVirtioGraphicsDeviceConfiguration | Virtio-GPU with Mesa/VirGL — hardware-accelerated 2D and basic 3D UI composition, one scanout no matter how many displays are declared |
| Windows | VZVirtioGraphicsDeviceConfiguration | Virtio Display-Only Driver baseline — 2D only, until the guest installs virtio-win drivers itself; Apple provides no DirectX translation layer |
The multi-head detail is easy to miss until you hit it: Virtualization.framework only honors multiple declared displays for a macOS guest. Ask a Linux or Windows guest for four displays and you still get one scanout back — a real limitation on the framework's own device model, not a bug in any particular app built on it.
Shared folders: one tag per folder, except on macOS
VZVirtioFileSystemDeviceConfiguration is the device type for a shared folder, backed by VirtioFS — but Linux/Windows and macOS guests use it in genuinely different shapes. Linux and Windows guests get one device per shared folder, each with its own tag, mounted independently inside the guest (mount -t virtiofs <tag> <mountpoint>). A macOS guest instead gets a single device using Apple's reserved automount tag, with every shared folder packed into one VZMultipleDirectoryShare — registering a second, separately-tagged device alongside it throws a validation error. The practical payoff for macOS 13+ guests: folders show up automatically under /Volumes/My Shared Files/ with no mount command needed at all, which is a real, documented behavior difference worth knowing before assuming the two guest types work the same way.
Rosetta for Linux rides the same device type: VZLinuxRosettaDirectoryShare is itself a kind of filesystem share, mounted with its own dedicated tag, gated on VZLinuxRosettaDirectoryShare.availability == .installed on the host before it's even offered.
The rest of the device inventory
Beyond storage, networking, graphics, and shared folders, a full guest configuration typically declares several more VIRTIO devices, each doing one narrow job:
- Vsock (
VZVirtioSocketDeviceConfiguration) — a paravirtualized socket channel between host and guest, with no network stack in between. This is the device AI Bridge is built on; see the full architecture. - Entropy (
VZVirtioEntropyDeviceConfiguration) — feeds host randomness into the guest's own entropy pool, so a freshly booted guest isn't generating cryptographic keys from a cold RNG. - Memory balloon (
VZVirtioTraditionalMemoryBalloonDeviceConfiguration) — lets the host reclaim memory a guest isn't using, without the guest OS ever seeing a resize. - Serial console (
VZVirtioConsoleDeviceSerialPortConfiguration) — typically backed by a host-side pseudo-terminal, giving a raw text console independent of the graphical display. - Clipboard — a separate
VZVirtioConsoleDeviceConfigurationcarrying Apple's own SPICE agent port (VZSpiceAgentPortAttachment), the sanctioned way to sync the system pasteboard with a guest's clipboard without pollingNSPasteboardfrom outside the framework.
The gotcha that silently breaks hibernate
This is worth its own section because it's the kind of bug that doesn't announce itself: VZVirtualMachineConfiguration.platform defaults to a fresh VZGenericPlatformConfiguration() if nothing sets it explicitly — and that default carries a brand-new, random machineIdentifier every single time the configuration object is built. Saved-state restore checks that identifier against the one the saved state came from, the same way it checks a macOS guest's hardware model. A builder that never assigns .platform gives every VM instance a different identity from the one before it, so every hibernate becomes unrestorable the moment it's saved — not intermittently, on every single attempt, because the mismatch is guaranteed rather than occasional. The fix is a read-then-create pattern: persist the generated identifier to disk on first creation, and read it back on every later build, exactly like the EFI variable store above.
The VM lifecycle, and what's actually fast
VZVirtualMachine(configuration:) constructs the VM from the validated configuration; start() boots it. From there, pause() and resume() are in-memory operations — milliseconds, no disk I/O — which is why they're the right response to the host Mac itself sleeping or waking. Hibernating a workspace to free its resources entirely is a different, heavier operation: saveMachineStateTo(url:) writes the VM's full state to disk, and it refuses to run against a VM that's still running — so a hibernate has to pause first, then save, then eventually restoreMachineStateFrom(url:) to bring it back. Four operations that look similar from the outside (pause, resume, hibernate, restore) are really two different mechanisms with very different costs, and conflating them is an easy way to build a "hibernate" feature that's actually just a slow pause.
Related reading: what a VM on Apple Silicon actually is, what determines VM performance on ARM, the architecture behind AI Bridge, and what's different about a macOS guest specifically. Or download Velo Workspaces and try it yourself.