QEMU in Docker: a real Windows 11 VM as a container
It is not Windows in a container. It is QEMU in a container with a Windows ISO and an unattended answer file — and the difference is three lines of compose.yml.
docker compose up, wait eight minutes, open localhost:8006, and there is a
Windows 11 desktop in your browser. It feels illegal the first time.
It is also widely misunderstood, including by me on day one. So before anything else:
Once that clicks, everything else about the setup stops being magic and starts being engineering — which is good, because you are going to have to debug it.
The compose file, and the three lines that matter
Here is the service, trimmed to the essentials:
services:
windowscomputer:
image: lukaskellerstein/windows-computer:0.0.4
environment:
RAM_SIZE: "4G"
CPU_CORES: "2"
devices:
- /dev/kvm
- /dev/net/tun
cap_add:
- NET_ADMIN
volumes:
- ./iso/Win11_24H2_English_x64.iso:/custom.iso
- ./scripts:/oem
- ./data:/data
- ./storage:/storage
stop_grace_period: 2m
Most of it is ordinary. Three parts are not.
/dev/kvm — the eight-minutes-versus-forty-minutes line
KVM is the Linux kernel’s hardware virtualisation interface. Pass the device through and QEMU executes guest instructions natively on your CPU. Leave it out and QEMU falls back to software emulation via TCG, which works, and is somewhere between five and twenty times slower depending on the workload.
In practice: a Windows 11 install that takes about eight minutes with KVM takes around forty without it. That is not a benchmark, that is the difference between a workflow and a coffee break. And it compounds — every boot, every test run, every time you nuke the disk to get back to a clean state.
If /dev/kvm does not exist on your host, you are either on a machine without
virtualisation extensions enabled in firmware, or inside a VM that does not
expose nested virtualisation. On a cloud VM this is a size question, and picking
the wrong size is a mistake you make exactly once. (More on that when this whole
thing moves to Kubernetes.)
/dev/net/tun + NET_ADMIN — the networking line
The guest needs a network interface. QEMU builds one by creating a TAP device —
a virtual Ethernet interface backed by a userspace program — and bridging it.
Creating a TAP device requires the /dev/net/tun character device, and
configuring the bridge requires CAP_NET_ADMIN.
Drop either and the container starts, Windows boots, and has no network. Which is a fun one to debug, because everything looks healthy right up until the guest tries to download anything.
/storage — the volume that is the operating system
This is the one that trips people up conceptually. The container is stateless. The volume is the installed OS — it holds the qcow2 disk image that QEMU writes into.
- Delete
./storage→ nextupruns the full Windows installation again. - Keep
./storage→ nextupboots the machine you already have.
That single fact is the whole disposability story. “Reset this desktop to a known
state” is rm -rf storage. It is also, six weeks later, the reason
VolumeSnapshots turn out to be the highest-leverage optimisation in the entire
platform — but that is a later post.
What is actually in the image
We forked dockur/windows rather than
consume it directly. The Dockerfile is short and tells you exactly what problem
each dependency solves:
FROM scratch AS build-amd64
COPY --from=qemux/qemu:7.11 / /
RUN apt-get update && apt-get --no-install-recommends -y install \
bc jq curl 7zip wsdd samba xz-utils wimtools \
dos2unix cabextract genisoimage libxml2-utils libarchive-tools
COPY --chmod=755 ./src /run/
COPY --chmod=755 ./assets /run/assets
ADD --chmod=664 https://github.com/qemus/virtiso-whql/releases/download/v1.9.45-0/virtio-win-1.9.45.tar.xz /drivers.txz
VOLUME /storage
EXPOSE 8006 3389
ENTRYPOINT ["/usr/bin/tini", "-s", "/run/entry.sh"]
Reading it as a list of problems:
qemux/qemu— the base. QEMU plus a web VNC endpoint on 8006.wimtools,cabextract,7zip,libarchive-tools— a Windows ISO is a bag of.wimarchives. To inject anything into the install you have to open it, modify it, and put it back.genisoimage— and then rebuild the ISO.libxml2-utils— for validating and patching the unattended answer file.samba+wsdd— the host exposes a SMB share to the guest. This is how files get in, and it becomes load-bearing in the next post.virtio-windrivers — Windows has no idea what a virtio disk or NIC is. Without these drivers baked in, the installer boots and then cannot see the disk it is supposed to install onto.
That last one is worth dwelling on. Virtio is the paravirtualised device standard that makes VM I/O fast — instead of emulating a real hardware NIC register by register, guest and host agree on a shared-memory protocol. Linux guests have virtio drivers in-tree. Windows does not, so they get slipstreamed into the install media at build time.
The unattended install
The assets/ directory holds over thirty XML files:
win11x64.xml win10x64.xml win2022.xml
win11x64-enterprise.xml win10x64-ltsc.xml win2019-eval.xml
win11x64-ltsc.xml win10x64-enterprise.xml win2016.xml
win11x64-iot.xml win7x64-ultimate.xml winvistax86.xml
...
These are Windows answer files — autounattend.xml. During setup, Windows
looks for one on the installation media and, if it finds it, uses it to answer
every question the wizard would otherwise ask: locale, keyboard, disk
partitioning, product key, EULA acceptance, the local account, whether to enable
telemetry, and — crucially — a FirstLogonCommands block that runs an arbitrary
command the first time the user logs in.
That last section is the entire hook for everything in the next post. It is the only place in an unattended Windows install where you get to say “and then run my code.”
The reason there are thirty of them is that the answer-file schema differs between Windows versions, editions and architectures in ways that are individually small and collectively maddening. A file that works for Windows 11 Pro will fail silently on Server 2019 — and “fail silently” means the installer stops at a dialog and waits forever, which in an automated system reads as a hang.
Why fork instead of using upstream
Three reasons, in ascending order of importance:
- A pinned, tagged artifact.
lukaskellerstein/windows-computer:0.0.4is ours. Upstream shipping a change on a Tuesday cannot alter what a benchmark run from last month was executing. - Our own OEM injection point. The
/oemmount is where our provisioning chain lives. We needed guarantees about when it runs relative to first logon. - The guest provisioning is the product. For a normal user of
dockur/windows, “Windows boots” is success. For us it is step zero — the interesting part is the seven control servers that have to be installed, firewalled, and auto-started inside it. Owning the image means owning that contract.
The trade-off is the usual one: we now maintain a fork and have to rebase it. Given that the guest bootstrap changed roughly weekly for two months, that was the right call. If you only need “a Windows that boots,” use upstream.
macOS, for completeness
Same trick, different guest, dramatically fewer lines:
services:
maccomputer:
image: dockurr/macos
environment:
VERSION: "15"
devices:
- /dev/kvm
- /dev/net/tun
cap_add:
- NET_ADMIN
volumes:
- ./storage:/storage
Identical shape: KVM, TUN, NET_ADMIN, a storage volume that is the OS. The
practical differences are that automated provisioning of macOS is far less
tractable — there is no autounattend.xml equivalent, so much more has to happen
by hand or via a snapshot taken after manual setup — and that Apple’s licence
terms are restrictive about virtualising macOS on non-Apple hardware. We used it
for cross-platform validation, not as a primary target.
What you have at this point
A single command that produces a real, disposable, network-connected Windows 11 desktop with a browser-accessible console. On a laptop. In under ten minutes.
That is genuinely a lot. It is also nowhere near enough, because the machine that boots is a stock Windows — no Python, no control servers, no automation, no way for anything outside to make it click a button.
Closing that gap took two months and produced the single most frustrating file in the repository. That is the next post.