Projects · Dockerios

Inside the Docker-OSX engine

A full teardown of Docker-OSX by sickcodes. What the container really contains, the OVMF and OpenCore boot chain, every QEMU flag and why it is there, the SMBIOS identity subsystem, the libguestfs bootdisk factory, how macOS gets downloaded from Apple, and where the design stops.

Updated Aug 22, 2026 · 39 min read

Read this before the first section, it prevents the main misunderstanding

Docker-OSX does not run macOS in a container. It cannot. Containers share the host kernel, and macOS needs the XNU kernel, which Linux is not.

What it actually does is put a full hardware virtual machine inside a container. The container is a delivery mechanism for a very carefully tuned QEMU invocation, plus the tooling to build the disks that invocation needs.

Once you read it as "a packaged hypervisor setup" rather than "macOS in Docker", every design decision in the project starts making sense.

Inside the Docker-OSX engine

I used this project in production work before I understood it. That is a slightly uncomfortable thing to admit, and it is exactly why this page exists.

Docker-OSX is one of those repositories where the interface is a single docker run line and the substance is about four thousand lines of very specific knowledge about Apple firmware, QEMU device models, and UEFI boot order. The interface is so clean that you can ship real work with it and never look inside.

So let us look inside. By the end of this page you should be able to explain, without hedging, why the emulated CPU has to be a fourteen year old Intel design, what a 384 MiB disk is doing in the boot order, and which five values decide whether Apple's servers believe your machine exists.

Three layers are in play, and confusing them is the source of nearly every wrong intuition about this project:

Preparing diagram

The container gives you distribution and reproducibility. The virtual machine gives you macOS. Docker contributes almost nothing to the hard part, and that is not a criticism, it is the whole insight.


1. The vocabulary, so nothing later is a mystery

I am going to define everything up front rather than dropping terms and hoping. Skim this, then come back when something bites.

1.1 Virtualisation words

TermWhat it actually means
EmulationSoftware pretends to be hardware by interpreting every instruction. Correct, portable, and roughly 10 to 100 times slower than native
VirtualisationGuest instructions run directly on the real CPU, with the processor's own virtualisation extensions trapping only the privileged operations. Near native speed
KVMKernel-based Virtual Machine. A Linux kernel module that turns the kernel into a hypervisor using Intel VT-x or AMD-V. Exposed to userspace as the device /dev/kvm. It does CPU and memory virtualisation, nothing else
QEMUThe other half. It builds the virtual machine: chipset, buses, disks, network cards, graphics, USB, firmware. It can emulate a CPU by itself, or hand CPU work to KVM and just do the devices
TCGTiny Code Generator, QEMU's built-in CPU emulator. This is the fallback when KVM is unavailable. macOS technically boots under it and is unusable in practice
Nested virtualisationRunning a hypervisor inside a virtual machine. Relevant because most cloud instances are already virtual machines, so /dev/kvm only exists there if the provider deliberately enables it
The split that explains the whole command line

KVM makes the guest fast. QEMU makes the guest believable.

Every difficult flag in Docker-OSX is a QEMU flag, because the difficulty was never performance. It was building a machine whose devices, firmware, and identity all look like something Apple ships.

1.2 Firmware and boot words

TermWhat it actually means
FirmwareThe code that runs before any operating system. It initialises hardware, then finds and launches a bootloader
UEFIUnified Extensible Firmware Interface, the modern replacement for legacy BIOS. It reads a FAT filesystem, looks for .efi executables, and runs them. Apple Macs have used UEFI-derived firmware since the Intel transition
OVMFOpen Virtual Machine Firmware. UEFI firmware built to run inside QEMU. Ships as two files: OVMF_CODE.fd, the read-only firmware itself, and OVMF_VARS.fd, the writable variable store
pflashParallel flash. How QEMU attaches firmware images, mimicking the physical flash chip a real board would have. This is why firmware is passed as a drive and not as a file path
NVRAMNon-volatile memory where firmware keeps settings and boot entries. In this setup, the OVMF_VARS file is the NVRAM
ESPEFI System Partition. A FAT32 partition with a specific GPT type GUID that firmware knows to search for bootloaders
OpenCoreA boot manager that loads before macOS and fixes up the machine on the way through: injecting a fake Apple SMBIOS identity, loading kernel extensions, patching ACPI tables, and applying kernel patches. This is the component doing the actual convincing
kextKernel extension, macOS's kernel module format. OpenCore can inject them at boot without them existing on the disk, which is how virtual hardware gets drivers
ACPIAdvanced Configuration and Power Interface. Firmware tables describing devices, power states, and topology. macOS reads these and is opinionated about what it finds

1.3 The Apple-specific words

This is the group that makes Docker-OSX different from any other QEMU wrapper.

TermWhat it actually means
SMBIOSSystem Management BIOS. A standard set of firmware tables describing the machine: manufacturer, product name, serial number, UUID. This is where iMacPro1,1 lives, and it is what macOS reads to decide what hardware it is on
SMCSystem Management Controller. A real physical chip in Intel Macs handling power, thermals, and fans. macOS refuses to boot without it, which makes it a hardware dongle by accident
OSKOperating System Key. A 64 character string macOS reads from the SMC as a basic authenticity check. The value has been public for well over a decade and QEMU accepts it as a device parameter
board-idAn Apple internal board identifier, in the form Mac-827FAC58A8FDFA22. Used to ask Apple's recovery servers which macOS build a given machine is entitled to. Not the same thing as the SMBIOS product name, and confusing the two is extremely common
MLBMain Logic Board serial number. A second serial, distinct from the system serial, tied to the board rather than the chassis. Apple's online services check both
ROMIn this context, a six byte value Apple derives from the primary network interface's MAC address. Used as part of hardware identity for iCloud and iMessage
SmUUIDThe system UUID reported in SMBIOS. Should be unique per machine and stable across reboots
SIPSystem Integrity Protection. macOS's kernel-level self defence: protected filesystem paths, restricted debugging, mandatory code signing for kernel extensions. Configured through an NVRAM variable called csr-active-config
iServicesShorthand for iCloud, iMessage, FaceTime, and the App Store. These validate hardware identity server side, which is why the identity values matter beyond just booting
HackintoshThe two decade old practice of running macOS on non-Apple hardware. Docker-OSX is, technically, a containerised Hackintosh with a very good user experience

1.4 Disks, networks, and image plumbing

TermWhat it actually means
qcow2QEMU Copy On Write v2. A sparse disk image format: it allocates space only as data is written, supports compression, snapshots, and backing files. A "200 GB" qcow2 can be a few gigabytes on disk
Backing fileA read-only base image that an overlay qcow2 references. Writes go to the overlay. This is how you get many machines from one golden disk without copying it
DMGApple Disk Image. The format macOS installers and recovery images ship in
AHCIAdvanced Host Controller Interface, the standard way SATA controllers are exposed. QEMU's ich9-ahci emulates an Intel chipset controller that macOS has native drivers for
Q35A QEMU machine type modelling an Intel Q35 chipset with PCI Express. Closer to real Mac hardware than QEMU's ancient default
virtioA family of paravirtualised devices designed for speed inside virtual machines. Linux and Windows have drivers. macOS does not, which is why this project uses slower emulated devices instead
SLIRPQEMU's usermode network stack. A NAT implemented entirely in the QEMU process, needing no root and no host network configuration. Outbound works, inbound requires explicit port forwards
hostfwdThe SLIRP option that maps a port on the QEMU process to a port inside the guest
libguestfs and guestfishA library, and its shell, for manipulating disk images without mounting them on the host. It boots a tiny throwaway Linux appliance to do the work. Crucial in a container, where you have no privilege to mount loop devices
If you only memorise one line from this section

macOS has no virtio drivers. That single fact dictates the emulated network card, the emulated disk controller, and the emulated graphics adapter, and it is the reason Docker-OSX performance has a ceiling that no amount of tuning removes.


2. What the container actually contains

Now the image itself. This is where the first surprises live.

2.1 Arch Linux, and a real Linux kernel inside a container

The base is archlinux:base-devel, and a user called arch is created with passwordless sudo:

Arch is a deliberate choice. A rolling release distribution gives current qemu-desktop and edk2-ovmf without maintaining backports, and macOS support in QEMU has moved fast enough that being a year behind genuinely hurts.

Then the virtualisation stack goes in:

And then the part that catches people out:

Why a container ships a Linux kernel it will never boot

That linux package is a full kernel image, and it is there for exactly one reason: libguestfs.

libguestfs edits disk images by booting a miniature Linux appliance and doing the work inside it, precisely so it never has to mount anything on the host. That appliance needs a kernel. The container therefore carries a kernel it never boots as a container, purely so it can build EFI bootdisks at runtime without privileged mounts.

It is a genuinely elegant answer to "how do I partition and populate a disk image with no privileges", and the comment in the Dockerfile is honest about the cost: it is a large part of why these images are so big.

2.2 Two repositories get cloned in

Docker-OSX is a wrapper, and it says so:

OSX-KVM by Dhiru Kholia supplies the OVMF firmware files, the OpenCore EFI tree, the Apple download scripts, and the Makefile. Docker-OSX itself is then cloned inside that tree, bringing the osx-serial-generator submodule with it.

Everything after this point runs with /home/arch/OSX-KVM as the working directory.

2.3 Launch.sh is written line by line at build time

This is my favourite bit of the whole repository, and it is the piece people never notice. The QEMU command is not a file in the git repo. It is generated during docker build, one line at a time, with tee -a:

Note the single quotes. The shell variables are written into the file literally, not expanded. So the script that lands in the image is full of unresolved placeholders like ${RAM:-4} and ${IMAGE_PATH:-...}, which only resolve when the container starts and bash reads the script.

What that buys, and what it costs

What it buys: the entire virtual machine is reconfigurable through Docker environment variables, with no config file, no template engine, and no rebuild. -e RAM=8 -e CORES=4 -e NETWORKING=e1000-82545em and you have a different machine. For a project whose users are running one-liners from a README, that is the right interface.

What it costs: the source of truth for the most important part of the system is a Dockerfile writing bash with heredocs. You cannot read the command line without either building the image or mentally concatenating forty tee calls. There is no schema, no validation, and a typo in an environment variable is a QEMU error at boot rather than a rejected input.

It is a configuration language implemented in shell string interpolation. Perfect for a single machine on your laptop, and the first thing that becomes load bearing in a bad way if you try to run a fleet.

Here is the layout the image ends up with:

Preparing diagram

3. The boot chain, in order

Now the interesting part. From docker run to a macOS desktop, here is every stage.

3.1 What the container entrypoint does before QEMU starts

The CMD is a long chain of conditionals. In sequence:

  1. If no BaseSystem.img exists, download macOS. Runs make, which invokes the Apple fetch script, then converts the result to compressed qcow2 and deletes the original DMG.
  2. Fix device ownership. chown on /dev/kvm and /dev/snd so the unprivileged arch user can open them.
  3. Choose a bootdisk. If NOPICKER=true, strip the installer drive out of Launch.sh with sed and switch to the nopicker bootdisk.
  4. Optionally generate a machine identity. GENERATE_UNIQUE=true mints fresh serials and builds a new bootdisk. GENERATE_SPECIFIC=true uses serials you supply.
  5. Start sshd inside the container, which is a separate thing from SSH into the guest.
  6. Exec Launch.sh.

The nopicker branch is worth seeing verbatim, because it is a good example of the project's style:

Skipping the boot picker is implemented by deleting lines from the QEMU command with a regular expression, at container start, in place. It works, it is completely legible, and it tells you a lot about where this project sits on the spectrum between script and system.

3.2 The three disks, and why the order matters

QEMU attaches three disk images to an emulated SATA controller, at fixed bus addresses:

BusImageSizeRole
sata.2OpenCore.qcow2384 MiBThe bootdisk. A tiny GPT disk holding an EFI System Partition with OpenCore and its config.plist. This is what the firmware boots
sata.3BaseSystem.imgUnder a gigabyte, varies by releaseThe macOS recovery installer, downloaded from Apple. Removed from the command line entirely once you no longer need to install
sata.4mac_hdd_ng.imgHundreds of GB declared, sparseThe actual system disk. Created empty on first boot, and where macOS ends up living
The 384 MiB disk is the entire trick

It is tempting to see the bootdisk as a detail. It is the most important disk of the three.

That small image is the only thing standing between "UEFI firmware in a QEMU virtual machine" and "hardware macOS is willing to boot on". It carries OpenCore, and OpenCore carries the SMBIOS identity, the injected kexts, the ACPI patches, and the kernel patches.

Wipe the big system disk and you lose your data. Wipe the 384 MiB bootdisk and the machine stops being a Mac.

3.3 The full sequence

Preparing diagram
Why OpenCore is needed at all, in one paragraph

macOS's own bootloader, boot.efi, is written for Apple firmware. It expects Apple-specific UEFI protocols, particular SMBIOS tables, an SMC to talk to, and a CPU whose reported family it recognises. Generic OVMF provides none of that.

OpenCore sits in between and fills every gap: it implements the missing Apple protocols, rewrites the SMBIOS tables in memory, injects kernel extensions for the emulated devices, patches ACPI, and applies binary patches to the XNU kernel itself so it accepts the CPU it has been given.

OpenCore is the compatibility layer. QEMU builds a plausible machine, and OpenCore makes it an Apple-shaped one.

4. The QEMU command line, flag by flag

This is the core of the project. Here is the generated Launch.sh, with the placeholders left as they appear in the image:

Every line is load bearing. Let us take them in groups.

4.1 CPU and machine

FlagWhy it is there
-machine q35Emulates an Intel Q35 chipset with PCI Express and modern AHCI. QEMU's default machine type models a 1996 era PIIX chipset, which macOS will not tolerate
accel=kvm:tcgUse KVM if /dev/kvm is available, otherwise silently fall back to software emulation. Convenient and dangerous, see the callout below
-cpu PenrynPresents a 45 nm Core 2 era Intel CPU. Not for compatibility with old software, but because OpenCore's kernel patches target a specific CPU family constant
vendor=GenuineIntelForces the CPUID vendor string. Matters on AMD hosts, where macOS would otherwise see AuthenticAMD and panic
+invtscInvariant timestamp counter. Advertises a TSC that ticks at a constant rate regardless of power state, which macOS relies on for timekeeping
vmware-cpuid-freq=onExposes the CPU frequency through the VMware CPUID leaf, giving the guest a sane clock speed instead of a nonsense one
+ssse3,+sse4.2,+popcnt,+avx,+aes,+xsave,+xsaveoptInstruction sets the Penryn model does not include by default but that modern macOS userspace requires. Without them, applications crash with illegal instruction faults even though the system boots
checkTells QEMU to warn if the host cannot actually provide a requested feature, instead of quietly dropping it and letting you debug it later at runtime
-smp 4,cores=4CPU topology. Overridable with SMP and CORES, or wholesale via CPU_STRING
-m 4000Memory in megabytes. Note the construction: RAM=4 becomes 4000, not 4096
Two traps hiding in that table

The KVM fallback is a silent failure mode. accel=kvm:tcg means a host without /dev/kvm still boots, using pure software emulation. It does not error. It does not warn. It just runs somewhere between 10 and 100 times slower, and the symptom is "macOS is incredibly slow", which sends people tuning RAM and cores for an afternoon. On a cloud instance without nested virtualisation enabled, this is exactly what happens.

RAM is not what you think. -m ${RAM:-4}000 is string concatenation, not arithmetic. RAM=4 gives you 4000 MB. RAM=16 gives you 16000 MB. Harmless, but if you are budgeting memory precisely, you are off by four percent per gigabyte.

Why Penryn, specifically

This confused me for a long time, because emulating an old CPU sounds like a downgrade.

The reason is that OpenCore applies binary patches to the XNU kernel to make it accept a CPU that is not in Apple's list of shipped hardware. Those patches are written against a specific CPU family constant, CPUFAMILY_INTEL_PENRYN, which is why the emulated CPU has to report that family for the patches to line up.

The individual instruction set flags then add back everything modern software actually needs. So the guest sees a Penryn shaped CPU with a 2020s feature set, which is a chimera that does not exist in nature and is exactly what the patch set expects.

Using -cpu host instead is a classic way to get a kernel panic that looks like a hardware fault.

4.2 The Apple SMC device

One flag, and it is the single most quoted line in the project:

What the OSK actually is

isa-applesmc is a QEMU device that emulates the Apple System Management Controller. Real Intel Macs have one as a physical chip, and macOS talks to it during early boot for power and thermal information.

Somewhere in that conversation, macOS reads a 64 byte value called the Operating System Key. The value is the string above, split across two SMC keys, and it reads as a message from an Apple engineer to whoever would eventually go looking. It has been circulating publicly for well over a decade, which is why QEMU can simply accept it as a device parameter.

Functionally it is a very soft authenticity check: a device only Apple hardware should have, holding a value only Apple should know. It stops nothing, but macOS will not boot without it, which makes it a mandatory piece of the command line.

It is the closest thing this whole stack has to a licence key, and it is a hardcoded string in a Dockerfile.

4.3 Firmware

Firmware is attached as two flash chips, mirroring how a real board works. OVMF_CODE.fd is the firmware and is read-only. OVMF_VARS-1024x768.fd is the writable NVRAM where boot entries and variables persist, and the filename indicates the framebuffer resolution the firmware is built for.

-smbios type=2 populates the baseboard information table. It is largely superseded by what OpenCore injects later, but the table has to exist for the firmware stage to be coherent.

4.4 Storage, and the absence of virtio

An emulated Intel ICH9 AHCI controller, with disks attached as ide-hd devices. No virtio-blk anywhere, because macOS has no virtio block driver. Emulated AHCI means every disk operation goes through full device emulation, which is a real and permanent performance cost.

One detail worth pausing on: snapshot=on on the bootdisk. That makes all writes to the OpenCore image go to a temporary overlay that is discarded when QEMU exits. The bootdisk is effectively immutable at runtime, which is a nice property. Your identity cannot be corrupted by the guest, and the same bootdisk file can be reused safely.

4.5 Networking

-netdev user is SLIRP, a NAT implemented in userspace inside the QEMU process. It needs no privileges and no host bridge configuration, which is precisely why it works in an unprivileged container. Outbound connections work. Inbound needs an explicit hostfwd, and two are set up by default: SSH and macOS Screen Sharing.

The emulated card is vmxnet3, a VMware paravirtualised NIC, with e1000-82545em available as an alternative through the NETWORKING variable. Again: not virtio, because macOS ships drivers for VMware's card and Intel's gigabit card, and not for virtio-net.

The MAC address is not cosmetic. It is the source of the ROM value in the machine identity, which Apple's services check.

4.6 Graphics, input, audio, and the monitor

FlagWhy it is there
-vga vmwareEmulates VMware SVGA II. macOS has a driver for it, which makes it the best available software graphics option. No 3D acceleration, no Metal, and the desktop feels like it
-device qemu-xhci plus usb-kbd and usb-tabletA USB 3 controller with a keyboard and an absolute-positioning pointer. usb-tablet rather than usb-mouse because absolute coordinates stop the guest cursor drifting away from your real one
-audiodev alsa plus ich9-intel-hdaAn emulated Intel HD Audio controller wired to the host's ALSA. This is why the run command passes --device /dev/snd
-monitor stdioThe QEMU monitor, its interactive control console, on the container's standard input and output. This is how docker attach gives you a QEMU prompt
-boot menu=onEnables the firmware boot menu, so you can pick a boot device manually when something goes wrong
the trailing EXTRA variableThe escape hatch. Arbitrary extra QEMU arguments from an environment variable. This is how VNC, USB passthrough, and GPU passthrough all get bolted on without changing the image
The monitor on stdio is a small decision with big consequences

Putting the QEMU monitor on standard I/O is why you can docker attach to a running container and type QEMU commands, which is genuinely useful for setting a VNC password or forcing a shutdown.

It also means the container's stdout is a shared channel between an interactive control console and whatever QEMU wants to log. There is no separate control socket, no structured status, and no way to ask "is the guest healthy" other than trying to open a TCP connection to it.


5. Convincing macOS it is a Mac

QEMU builds the hardware. OpenCore makes it Apple-shaped. But the identity of that Apple-shaped machine is a specific set of values, and this is where the second repository earns its place.

5.1 The five values

Everything comes down to five, plus one derived:

ValueExampleWhat it is
DEVICE_MODELiMacPro1,1The SMBIOS product name. Decides which hardware profile macOS thinks it is running, which drives power management, supported features, and OS version eligibility
SERIALC02TM2ZBHX87System serial number. Encodes factory, production week, and model in Apple's own scheme, so it cannot be random
BOARD_SERIALC02717306J9JG361MThe MLB, the logic board serial. Longer, different format, checked independently
UUID007076A6-F2A2-...SMBIOS system UUID. Any valid uppercase UUID works
MAC_ADDRESSA8:5C:2C:9A:46:2FPrimary network interface MAC. Must sit in an Apple-owned OUI range to be plausible
ROMa85c2c9a462fDerived, not chosen: the MAC lowercased with colons removed
Booting versus being believed

This is the distinction that makes the identity subsystem worth understanding.

For macOS to boot, the values only have to be well formed. Nobody checks them against reality.

For macOS to use Apple's services, iCloud, iMessage, FaceTime, the App Store, the values get sent to Apple and validated server side as a set. A serial that does not match its claimed model, or a MAC outside Apple's ranges, gets the account flagged rather than the machine rejected.

Docker-OSX defaults to a hardcoded stock identity, which is fine for CI and build work where nobody signs in. The generator exists for when you need the machine to look individually real.

5.2 How plausible serials get generated

The generator does not invent serials from scratch. It builds Apple's own tool:

macserial comes from OpenCorePkg and encodes Apple's serial number scheme, including which prefixes belong to which models and how production dates are packed. Ask it for an iMacPro1,1 serial and you get one that decodes correctly, because it was constructed by the same rules Apple uses.

The MAC address is handled with equal care, and this is my favourite detail in the repository:

It downloads Wireshark's OUI database, greps it for Apple, Inc., picks a random Apple-registered prefix, and appends three random bytes:

Using a packet analyser's vendor table as a source of legitimate-looking Apple hardware addresses is both completely reasonable and slightly delightful.

Here is what a generation run produces:

Minting one machine identity
$

That env file is the interesting artefact. It is a portable identity: mount it into another container with GENERATE_SPECIFIC=true and you get the same machine again. It is the closest thing Docker-OSX has to a machine record.

5.3 Where the values actually land

They get written into OpenCore's config.plist, under PlatformInfo and Generic. The templates ship with placeholders:

And substitution is, as you would now expect, sed:

SpoofVendor being true tells OpenCore to present Apple as the SMBIOS manufacturer instead of whatever the firmware would otherwise report.

5.4 System Integrity Protection, and how it gets relaxed

The plists also carry an NVRAM setting:

That base64 decodes to the bytes 67 00 00 00, which is the little-endian encoding of 0x67. It is a bitmask, and this particular value is the well worn Hackintosh setting for partial SIP disablement: allow unsigned kernel extensions and kernel debugging, keep the rest of the protections.

There is also a nosip variant that goes considerably further, through kernel boot arguments:

amfi_get_out_of_my_way=0x1 disables AppleMobileFileIntegrity, the subsystem enforcing code signing. That is a deliberately blunt instrument for research work, and it is worth knowing which image you picked before you run anything you care about on it.


6. The bootdisk factory

We now know what goes into the bootdisk. Here is how the 384 MiB image is physically built, inside a container, with no privileges.

6.1 The two scripts

ScriptWhat it does
generate-unique-machine-values.shMints fresh random identities with macserial, writes CSV, TSV, and env files, and optionally builds a bootdisk per identity. This is GENERATE_UNIQUE=true
generate-specific-bootdisk.shTakes an identity you already have and builds exactly one bootdisk from it. This is GENERATE_SPECIFIC=true, and it is also what the Dockerfile itself calls twice at build time to create the stock bootdisks
opencore-image-ng.shThe actual image builder, called by both. Originally from Gerd Hoffmann's imagefish project

Both generators start by fetching the plist template over HTTP, from a URL you can override:

The plist arrives over the network at container start

MASTER_PLIST_URL is an environment variable with a raw.githubusercontent.com default, and the script curls it at runtime whenever you ask for generated serials.

For a laptop, this is flexibility: point it at your own plist and you have customised OpenCore without rebuilding anything. For anything automated, it means a boot-time dependency on GitHub's availability and on the current contents of a branch. There is no pinned revision and no checksum.

6.2 How the image gets built without root

opencore-image-ng.sh drives guestfish, the libguestfs shell. The sequence is short and readable:

Reading it top to bottom: create a 384 MiB qcow2, give it a GPT label, add two partitions, mark the first as an EFI System Partition using the standard type GUID C12A7328-F81F-11D2-BA4B-00A0C93EC93B, format both as FAT, then copy in the OpenCore EFI tree, its drivers, its kernel extensions, its ACPI tables, Apple's boot resources, and the patched config.plist.

Why this approach is the right one for a container

The obvious way to populate a disk image is losetup, then mount, then cp. That needs CAP_SYS_ADMIN and a loop device on the host, so it needs a privileged container.

libguestfs sidesteps the problem entirely by booting a throwaway Linux appliance inside a nested QEMU process and doing the filesystem work in there. The host kernel never mounts anything.

That is why the container ships a Linux kernel, and it is the reason bootdisks can be generated on the fly at container start without privileges. The cost is image size and a slow first run. The benefit is that identity generation is a normal, unprivileged runtime operation.

6.3 The plist variants

FilePickerWhat it is for
config-custom.plistshownThe default. You get the OpenCore boot menu, which you need during installation
config-nopicker-custom.plisthiddenShowPicker is false. Boots straight to the installed system, which is what you want for automation
config-custom-sonoma.plist and its nopicker twinbothNewer macOS needs additional kernel patches, including work to make Apple ID sign-in behave inside a virtual machine
config-custom-nosip.plistshownAggressive boot arguments that disable code signing enforcement. Research use
config-legacy.plist and its nopicker twinbothOlder OpenCore schema, for older macOS releases

Note that "nopicker" is implemented twice, at two different layers: ShowPicker in the plist, and the sed that strips the installer drive from the QEMU command line. Both are needed, because hiding the menu does not help if the installer disk is still the most attractive boot candidate.


7. Getting macOS in the first place

The container ships no Apple software. It cannot. So macOS is downloaded on first boot, and there are two different mechanisms in the codebase for doing it.

7.1 The recovery route

This is the default path, triggered from the container CMD:

make runs the OSX-KVM Makefile, which calls the fetch script. That script talks to osrecovery.apple.com, the same endpoint a real Mac uses for internet recovery, identifying itself with the user agent InternetRecovery/1.0. It gets a session cookie, then posts a request containing a board-id, and receives back a BaseSystem.dmg plus a .chunklist.

The chunklist is Apple's integrity mechanism: a file with a CNKL header containing a SHA-256 hash per chunk of the image, signed so the download can be verified rather than trusted.

board-id versus SystemProductName, the distinction people get wrong

Both identify hardware, and they are used at completely different moments.

board-id, like Mac-827FAC58A8FDFA22, is what you send to Apple's recovery servers to say "I am this board, give me the macOS I am entitled to". It only matters at download time, and each macOS release has its own.

SystemProductName, like iMacPro1,1, is what goes into the SMBIOS tables through OpenCore. It matters at boot time and at every moment after, and it is what macOS itself reads.

Changing SHORTNAME=sonoma changes the board-id used for the download. It does not change the identity of the resulting machine. Different layer, different purpose.

Then qemu-img convert BaseSystem.dmg -O qcow2 -p -c turns the DMG into a compressed qcow2. There is no dmg2img in this path, and no full macOS installer either. What you get is the recovery environment, roughly 2 to 3 GiB, and the installer downloads the rest of the operating system itself once you start the install.

7.2 The catalog route

A second script in the repository root takes a different approach: it queries Apple's software update catalogs at swscan.apple.com, parses the gzipped property list, and pulls package URLs from it. This is the mechanism the Helm chart uses. Same destination, different Apple API, and it can fetch full installer packages rather than just recovery images.

7.3 The empty system disk

The same make invocation that fetches the recovery image also creates the disk macOS will install onto, with one command from the upstream OSX-KVM Makefile:

qcow2 is sparse, so declaring a few hundred gigabytes costs almost nothing until data is actually written. This is why the Dockerfile header can casually claim "it will build a 200GB container" without producing a 200 GB image.

A small piece of archaeology worth noticing

That Dockerfile header, and a --build-arg SIZE=200G in the documented examples, both suggest disk size is a build parameter of Docker-OSX. Read the Dockerfile carefully and ARG SIZE=200G is declared and then never referenced again.

The real size comes from the upstream Makefile's own default, which Docker-OSX does not override. The SIZE argument is vestigial: a leftover from an earlier design that still appears in the documentation.

It is a tiny thing, and it is the kind of tiny thing worth knowing about a tool you depend on. The documented knob is not connected to anything.

7.4 What first boot looks like

First run, cold start, nothing cached
$

Those two outputs are the whole page in one place. The guest reports itself as an iMac Pro with a serial number that decodes correctly against Apple's own scheme, and it is a QEMU process on a Linux box. Nothing in the operating system suspects otherwise, because from inside, the SMBIOS tables are the truth.


8. The image variants, and one badly named tag

Docker-OSX publishes several images. They are all thin layers on the same base, and the differences are almost entirely environment variables.

VariantWhat changes
base, plus version tags such as :sonoma and :venturaThe full installer experience. Ships an empty system disk and downloads a recovery image on first boot. Version tags only change SHORTNAME, which changes the board-id used for the download
:nakedDeletes the baked-in system disk and expects one mounted at /image. NOPICKER=true, headless display on :99 by default. This is the variant for bringing your own prepared disk
:autoDownloads a roughly 20 GB pre-installed macOS disk at build time. Default guest credentials user and alpine, and a terms-of-use environment variable you must set
:naked-autoThe same automation, with the disk supplied externally instead of baked in
VNC editionsPrepend a TigerVNC server on display :99 before launching QEMU, so no host X11 socket is needed. A random password is generated at build and printed in the build log
The :auto tag does not automate what you think it automates

I assumed for a long time that :auto drove the macOS installer automatically, scripting Disk Utility and the setup assistant.

It does not. There is no expect script, no UI automation, no supervisord anywhere in the repository. :auto ships a disk on which macOS is already installed, and what it automates is getting a shell on it. The entrypoint boots the machine and then loops:

So the readiness signal for "macOS has finished booting" is a successful SSH key copy. Nothing else. There is no other health check in the system, and once you notice that, a lot of the operational behaviour makes sense.

The corollary is that a fresh install on the base image is a manual, graphical process: boot the picker, open Disk Utility, erase the blank disk, run the installer, wait. Nobody has automated it, because the installer is a GUI and GUIs are miserable to script.


9. State, persistence, and why these images are enormous

Everything mutable lives in the qcow2 files, so persistence is entirely a question of where those files are.

ApproachHow it behaves
Disk inside the containerDefault for the base image. Writes land in the container's writable layer, which is slow, invisible to the host, and gone when the container is removed
Disk bind mounted, the naked pattern-v ./mac_hdd_ng.img:/image. The disk is a normal host file. Portable, backupable, and the only sane option for anything long lived
docker commitDocumented and widely used, and it bakes tens of gigabytes of guest disk into an image layer. It works. It is not a good idea
BootdiskRuns with snapshot=on, so guest writes are discarded on exit. Identity is effectively immutable at runtime

The size problem has three independent causes, and it is worth separating them:

  1. The Arch base plus the full QEMU and libvirt stack. Unavoidable given what the container does.
  2. The Linux kernel and libguestfs. The price of unprivileged bootdisk generation, and the Dockerfile comment calls this out by name.
  3. The guest disk. Either an empty sparse qcow2, or in the :auto case a 20 GB download committed into an image layer.
What is missing from that table, and it is deliberate

There is no use of qcow2 backing files anywhere in the runtime path.

That is the feature where many overlay images share one read-only base, so you can start fifty machines from one golden disk and each only stores its own writes. The format supports it natively. Docker-OSX does not use it, because Docker-OSX is built around the assumption of one machine at a time, and for one machine it buys you nothing.

It is the single clearest technical marker of the project's scope. Not a flaw, just a boundary.


10. Getting in: the access paths

There are more ways in than people realise, and they operate at different layers. The one that confuses everybody is SSH, because there are two hops and two different SSH servers.

Preparing diagram
PathHow it works
SSH to macOSTwo hops. Docker maps host 50922 to container 10022, then QEMU's hostfwd maps container 10022 to guest 22. The :auto images default to user and alpine
X11 on the hostMount /tmp/.X11-unix and set DISPLAY. QEMU opens a window on your desktop directly. Simple, and it ties the container to a graphical session
VNC, TigerVNC editionAn Xvnc server on display :99 inside the container, QEMU renders into it, you connect to container port 5999
VNC, QEMU native-e EXTRA="-display none -vnc 0.0.0.0:99,password=on". QEMU serves VNC itself with no X server at all, and you set the password through the monitor on stdio
HeadlessXvfb :99, a virtual framebuffer nobody looks at. Used by the naked and auto variants so QEMU has a display to open without anyone watching
macOS Screen SharingPort 5900 is forwarded by default, so if you enable Screen Sharing in the guest you get native macOS remote desktop rather than a framebuffer
QEMU monitordocker attach puts you on the monitor console, because it is wired to stdio
iPhone passthroughThe genuinely clever one, see below
How a physical iPhone reaches a virtualised Mac

This is documented in the README and it is a neat piece of plumbing, because it does not use USB passthrough at all.

On the Linux host, usbmuxd is the daemon that speaks Apple's USB multiplexing protocol to connected iOS devices, exposing it on a Unix socket. socat publishes that socket on a TCP port. Then usbfluxd inside the guest connects to that port and re-presents the device to macOS as if it were locally attached.

So the iPhone is not passed through as a USB device. Its protocol is proxied over TCP. Xcode in the guest sees a real device, and the emulated USB controller is never involved.


11. Performance, honestly

FactorReality
CPUNear native with KVM. This part is genuinely fine
DiskEmulated AHCI plus qcow2. No virtio-blk, because macOS has no driver. A permanent tax on every I/O operation
NetworkEmulated vmxnet3 over SLIRP usermode NAT. Two layers of overhead, and SLIRP is not built for throughput
Graphics-vga vmware, software only. No Metal, no hardware acceleration. The desktop is usable and never pleasant
MemoryNo hugepages configured, no NUMA awareness, no ballooning
SchedulingNo CPU pinning, no isolated cores. The guest competes with everything else on the host
GPU passthroughPossible via vfio-pci through the EXTRA variable, documented in the Helm directory. Needs IOMMU, a spare GPU, and does not combine well with VNC
The performance ceiling is not a tuning problem

Almost every tuning knob people reach for here is already at its best setting, and the remaining cost is structural: macOS does not have paravirtualised drivers for the fast device models, so the fast device models cannot be used.

Which means the useful performance question for this stack is never "how do I make one machine faster". It is "how many of these can a host carry, and what is each one actually waiting on". The bottleneck is device emulation, and the fix is a different device model that does not exist.


12. The design boundary, stated plainly

Docker-OSX solves one problem extremely well: get a working macOS virtual machine on a Linux box, in one command, reproducibly. Judged against that, it is excellent, and the fact that people run real work on it is the proof.

It is worth being precise about where that design stops, because the boundary is informative rather than critical.

PropertyHow Docker-OSX handles it
ConfigurationEnvironment variables interpolated into a shell script generated at build time. No schema, no validation, errors surface as QEMU failures
Machine identityGenerated at container start, or hardcoded stock values. Not stored, not tracked, not reconciled
HealthOne signal: can I complete an SSH handshake. No boot progress, no readiness distinct from liveness, no metrics
Disk lifecycleA file, either inside the container or bind mounted. No golden image, no backing file overlays, no snapshot workflow
MultiplicityOne virtual machine per container, by construction. Everything from the fixed SATA bus numbers to the stdio monitor assumes one
Control surfaceThe QEMU monitor on stdio. Interactive, human oriented, not a programmable interface
External dependencies at bootMay reach GitHub for a plist and Apple for an installer image, unpinned
The right way to read that table

None of those are bugs. Every one is the correct answer to the question the project set out to answer.

If your goal is "one Mac, on my machine, right now", then storing identity in a file is fine, an SSH handshake is a perfectly good readiness check, and a shell script is a better configuration interface than a YAML schema. The project is well matched to its problem, which is the highest compliment you can pay a design.

The table is a description of scope, not a list of defects. And it is exactly the list you would need to consult before attempting anything larger.


13. The five ideas actually worth keeping

If I had to compress the entire teardown into what I would not want to relearn:

  1. The container is a packaging decision, not a virtualisation one. All the hard engineering is in the QEMU command line and the OpenCore configuration. Docker contributes distribution and reproducibility, which is valuable and is not the same as being the mechanism.

  2. Identity is a first class artefact, and it is five values. Model, serial, board serial, UUID, MAC, with ROM derived from the MAC. They are generated by Apple's own algorithm through macserial, injected through OpenCore's config.plist, and they decide whether the machine merely boots or is actually believed.

  3. The 384 MiB bootdisk carries everything that makes it a Mac. SMBIOS spoofing, injected kexts, ACPI patches, kernel patches, SIP configuration. It is built at runtime by libguestfs specifically so it can be built without privileges.

  4. macOS has no virtio drivers, and that decides the hardware. Emulated AHCI, vmxnet3, -vga vmware. Every performance characteristic of this stack follows from that one fact.

  5. The readiness signal is an SSH handshake. That is the only thing in the system that knows whether the guest is alive, and it is the detail I would keep at the front of my mind for anything operational.

What I got out of writing this

I could already use this project. Now I can explain why every part of it is shaped the way it is, including the parts I would have called weird a week ago and now think are correct.

The sed on the QEMU command line, the plist fetched over HTTP at boot, the kernel shipped in a container: all three looked like hacks when I started reading and all three are the right call for a tool whose interface is a single docker run line.

That is the real value of a teardown. Not a list of things to improve, but a clear view of which decisions were forced, which were chosen, and which were scoped.