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.
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:
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
| Term | What it actually means |
|---|---|
| Emulation | Software pretends to be hardware by interpreting every instruction. Correct, portable, and roughly 10 to 100 times slower than native |
| Virtualisation | Guest instructions run directly on the real CPU, with the processor's own virtualisation extensions trapping only the privileged operations. Near native speed |
| KVM | Kernel-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 |
| QEMU | The 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 |
| TCG | Tiny 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 virtualisation | Running 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 |
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
| Term | What it actually means |
|---|---|
| Firmware | The code that runs before any operating system. It initialises hardware, then finds and launches a bootloader |
| UEFI | Unified 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 |
| OVMF | Open 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 |
| pflash | Parallel 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 |
| NVRAM | Non-volatile memory where firmware keeps settings and boot entries. In this setup, the OVMF_VARS file is the NVRAM |
| ESP | EFI System Partition. A FAT32 partition with a specific GPT type GUID that firmware knows to search for bootloaders |
| OpenCore | A 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 |
| kext | Kernel 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 |
| ACPI | Advanced 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.
| Term | What it actually means |
|---|---|
| SMBIOS | System 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 |
| SMC | System 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 |
| OSK | Operating 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-id | An 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 |
| MLB | Main 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 |
| ROM | In 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 |
| SmUUID | The system UUID reported in SMBIOS. Should be unique per machine and stable across reboots |
| SIP | System 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 |
| iServices | Shorthand for iCloud, iMessage, FaceTime, and the App Store. These validate hardware identity server side, which is why the identity values matter beyond just booting |
| Hackintosh | The 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
| Term | What it actually means |
|---|---|
| qcow2 | QEMU 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 file | A 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 |
| DMG | Apple Disk Image. The format macOS installers and recovery images ship in |
| AHCI | Advanced 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 |
| Q35 | A QEMU machine type modelling an Intel Q35 chipset with PCI Express. Closer to real Mac hardware than QEMU's ancient default |
| virtio | A 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 |
| SLIRP | QEMU'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 |
| hostfwd | The SLIRP option that maps a port on the QEMU process to a port inside the guest |
| libguestfs and guestfish | A 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 |
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:
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 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:
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:
- If no
BaseSystem.imgexists, download macOS. Runsmake, which invokes the Apple fetch script, then converts the result to compressed qcow2 and deletes the original DMG. - Fix device ownership.
chownon/dev/kvmand/dev/sndso the unprivilegedarchuser can open them. - Choose a bootdisk. If
NOPICKER=true, strip the installer drive out ofLaunch.shwithsedand switch to the nopicker bootdisk. - Optionally generate a machine identity.
GENERATE_UNIQUE=truemints fresh serials and builds a new bootdisk.GENERATE_SPECIFIC=trueuses serials you supply. - Start sshd inside the container, which is a separate thing from SSH into the guest.
- 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:
| Bus | Image | Size | Role |
|---|---|---|---|
sata.2 | OpenCore.qcow2 | 384 MiB | The bootdisk. A tiny GPT disk holding an EFI System Partition with OpenCore and its config.plist. This is what the firmware boots |
sata.3 | BaseSystem.img | Under a gigabyte, varies by release | The macOS recovery installer, downloaded from Apple. Removed from the command line entirely once you no longer need to install |
sata.4 | mac_hdd_ng.img | Hundreds of GB declared, sparse | The actual system disk. Created empty on first boot, and where macOS ends up living |
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
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
| Flag | Why it is there |
|---|---|
-machine q35 | Emulates 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:tcg | Use KVM if /dev/kvm is available, otherwise silently fall back to software emulation. Convenient and dangerous, see the callout below |
-cpu Penryn | Presents 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=GenuineIntel | Forces the CPUID vendor string. Matters on AMD hosts, where macOS would otherwise see AuthenticAMD and panic |
+invtsc | Invariant timestamp counter. Advertises a TSC that ticks at a constant rate regardless of power state, which macOS relies on for timekeeping |
vmware-cpuid-freq=on | Exposes 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,+xsaveopt | Instruction 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 |
check | Tells 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=4 | CPU topology. Overridable with SMP and CORES, or wholesale via CPU_STRING |
-m 4000 | Memory in megabytes. Note the construction: RAM=4 becomes 4000, not 4096 |
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.
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:
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
| Flag | Why it is there |
|---|---|
-vga vmware | Emulates 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-tablet | A 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-hda | An emulated Intel HD Audio controller wired to the host's ALSA. This is why the run command passes --device /dev/snd |
-monitor stdio | The 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=on | Enables the firmware boot menu, so you can pick a boot device manually when something goes wrong |
the trailing EXTRA variable | The 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 |
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:
| Value | Example | What it is |
|---|---|---|
DEVICE_MODEL | iMacPro1,1 | The SMBIOS product name. Decides which hardware profile macOS thinks it is running, which drives power management, supported features, and OS version eligibility |
SERIAL | C02TM2ZBHX87 | System serial number. Encodes factory, production week, and model in Apple's own scheme, so it cannot be random |
BOARD_SERIAL | C02717306J9JG361M | The MLB, the logic board serial. Longer, different format, checked independently |
UUID | 007076A6-F2A2-... | SMBIOS system UUID. Any valid uppercase UUID works |
MAC_ADDRESS | A8:5C:2C:9A:46:2F | Primary network interface MAC. Must sit in an Apple-owned OUI range to be plausible |
ROM | a85c2c9a462f | Derived, not chosen: the MAC lowercased with colons removed |
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:
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
| Script | What it does |
|---|---|
generate-unique-machine-values.sh | Mints 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.sh | Takes 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.sh | The 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:
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.
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
| File | Picker | What it is for |
|---|---|---|
config-custom.plist | shown | The default. You get the OpenCore boot menu, which you need during installation |
config-nopicker-custom.plist | hidden | ShowPicker is false. Boots straight to the installed system, which is what you want for automation |
config-custom-sonoma.plist and its nopicker twin | both | Newer macOS needs additional kernel patches, including work to make Apple ID sign-in behave inside a virtual machine |
config-custom-nosip.plist | shown | Aggressive boot arguments that disable code signing enforcement. Research use |
config-legacy.plist and its nopicker twin | both | Older 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.
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.
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
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.
| Variant | What changes |
|---|---|
base, plus version tags such as :sonoma and :ventura | The 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 |
:naked | Deletes 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 |
:auto | Downloads 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-auto | The same automation, with the disk supplied externally instead of baked in |
| VNC editions | Prepend 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 |
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.
| Approach | How it behaves |
|---|---|
| Disk inside the container | Default 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 commit | Documented and widely used, and it bakes tens of gigabytes of guest disk into an image layer. It works. It is not a good idea |
| Bootdisk | Runs 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:
- The Arch base plus the full QEMU and libvirt stack. Unavoidable given what the container does.
- The Linux kernel and libguestfs. The price of unprivileged bootdisk generation, and the Dockerfile comment calls this out by name.
- The guest disk. Either an empty sparse qcow2, or in the
:autocase a 20 GB download committed into an image layer.
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.
| Path | How it works |
|---|---|
| SSH to macOS | Two 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 host | Mount /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 edition | An 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 |
| Headless | Xvfb :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 Sharing | Port 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 monitor | docker attach puts you on the monitor console, because it is wired to stdio |
| iPhone passthrough | The genuinely clever one, see below |
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
| Factor | Reality |
|---|---|
| CPU | Near native with KVM. This part is genuinely fine |
| Disk | Emulated AHCI plus qcow2. No virtio-blk, because macOS has no driver. A permanent tax on every I/O operation |
| Network | Emulated 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 |
| Memory | No hugepages configured, no NUMA awareness, no ballooning |
| Scheduling | No CPU pinning, no isolated cores. The guest competes with everything else on the host |
| GPU passthrough | Possible via vfio-pci through the EXTRA variable, documented in the Helm directory. Needs IOMMU, a spare GPU, and does not combine well with VNC |
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.
| Property | How Docker-OSX handles it |
|---|---|
| Configuration | Environment variables interpolated into a shell script generated at build time. No schema, no validation, errors surface as QEMU failures |
| Machine identity | Generated at container start, or hardcoded stock values. Not stored, not tracked, not reconciled |
| Health | One signal: can I complete an SSH handshake. No boot progress, no readiness distinct from liveness, no metrics |
| Disk lifecycle | A file, either inside the container or bind mounted. No golden image, no backing file overlays, no snapshot workflow |
| Multiplicity | One virtual machine per container, by construction. Everything from the fixed SATA bus numbers to the stdio monitor assumes one |
| Control surface | The QEMU monitor on stdio. Interactive, human oriented, not a programmable interface |
| External dependencies at boot | May reach GitHub for a plist and Apple for an installer image, unpinned |
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:
-
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.
-
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'sconfig.plist, and they decide whether the machine merely boots or is actually believed. -
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.
-
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. -
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.
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.