How to setup WoA VMs on a KVM host
KVM host setup: Windows 11 ARM64 buildbot VMs
Target: Ubuntu 26.04 LTS Server, aarch64, bare metal. Result: 4 Windows 11 ARM64 VMs on a bridged LAN, fixed VNC ports, reachable from a macOS/Linux workstation.
0. Prerequisites — verify before starting
sudo apt update
sudo apt install -y libvirt-clients
virt-host-validate qemu
virt-host-validate must pass hardware virtualization. There is no kvm-ok on ARM — the aarch64 check is EL2 support, not VT-x/AMD-V flags. If this fails, stop: nested virt on a cloud aarch64 instance often lacks EL2 passthrough.
Have ready:
Windows 11 ARM64 ISO from VLSC/MSDN. Verify
sha256sumagainst Microsoft's published hash — not a hash provided by a third party, and never one derived from the same media you are copying.Host sized for the pool. This build: 64 vCPU / 192 GB RAM / 1 TB disk across 4 VMs.
ISO integrity check (do this every time)
sudo mkdir -p /mnt/winiso
sudo mount -o loop,ro /path/to/windows.ISO /mnt/winiso
ls -la /mnt/winiso/sources/install.wim
ls -la /path/to/windows.ISO
sudo umount /mnt/winiso
install.wim must be smaller than the ISO file containing it. A truncated image keeps intact UDF metadata describing the full-size install.wim, so it mounts fine, passes file, and boots far enough to show a boot prompt — then fails with timeouts or BCD errors deep into the process. This exact failure cost a full day on the first build. Never dd install media off a USB stick that has thrown Medium Error in dmesg; get a fresh copy from the original source.
1. Packages
sudo apt install -y \
qemu-system-arm qemu-efi-aarch64 \
libvirt-daemon-system libvirt-clients virtinst bridge-utils \
swtpm swtpm-tools
qemu-efi-aarch64— mandatory. aarch64 has no legacy BIOS; guests boot via UEFI (AAVMF).swtpm— mandatory for Windows 11 (TPM 2.0 presence check).
sudo usermod -aG libvirt,kvm $USER
newgrp libvirt
sudo systemctl enable --now libvirtd
2. Networking — bond + bridge
Skip if you have only one network card.
Two NICs on the same subnet with separate IPs is not redundancy — no failover, and it invites ARP flux. Bond them into one interface with one IP, then bridge on top.
Do this from console/IPMI, never over SSH. A bonding misconfiguration drops the link entirely.
/etc/netplan/01-bond-bridge.yaml (substitute your interface names, address, gateway):
network:
version: 2
ethernets:
eno1:
dhcp4: no
eno2:
dhcp4: no
bonds:
bond0:
interfaces: [eno1, eno2]
parameters:
mode: active-backup
primary: eno1
mii-monitor-interval: 100
bridges:
br0:
interfaces: [bond0]
addresses: [192.168.78.134/24]
routes:
- to: default
via: 192.168.78.1
nameservers:
addresses: [192.168.78.1]
parameters:
stp: true
forward-delay: 4
sudo netplan apply
cat /proc/net/bonding/bond0 # confirm one active slave, one backup, MII status up
ip a show br0
active-backup needs no switch-side configuration. Use LACP only if the switch ports are already configured for it.
3. Storage layout
Keep shared install media outside any libvirt storage pool. Anything inside the pool directory is reachable by virsh storage operations, and virsh undefine --remove-all-storage deletes every disk source attached to a domain — including read-only CD-ROMs shared across VMs.
sudo mkdir -p /var/lib/libvirt/isos # NOT a pool — plain directory
sudo mkdir -p /var/lib/libvirt/images
sudo virsh pool-define-as vmpool dir --target /var/lib/libvirt/images
sudo virsh pool-build vmpool
sudo virsh pool-start vmpool
sudo virsh pool-autostart vmpool
virsh pool-info vmpool
Cross-check pool-info capacity against df -h on the target path. If they don’t match, the pool is not pointed where you think.
Place both ISOs in /var/lib/libvirt/isos/:
sudo wget -O /var/lib/libvirt/isos/virtio-win.iso \
https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso
# plus your verified Windows ARM64 ISO
Note: virtio-fs (viofs) has no ARM64 driver as of 0.1.266 — do not plan on virtio-fs shared folders for these guests. Use network shares.
4. Create the VMs
Four settings below are non-obvious and each one caused a distinct failure on the first build. Do not drop them:
Setting | Why |
|---|---|
|
|
|
|
Disk and Windows ISO on | One controller means one driver ( |
virtio-win ISO on | WinPE has no virtio driver. If the driver ISO sits on virtio-scsi, WinPE cannot see the disk or the ISO holding the driver for it. USB is natively supported. |
Template — substitute name, vCPUs, memory:
WIN_ISO=/var/lib/libvirt/isos/<windows-arm64>.ISO
VIRTIO_ISO=/var/lib/libvirt/isos/virtio-win.iso
virt-install \
--name <VM_NAME> \
--vcpus <N> --memory <MB> \
--controller type=scsi,model=virtio-scsi \
--disk pool=vmpool,size=256,format=qcow2,bus=scsi \
--disk "$WIN_ISO",device=cdrom,bus=scsi,readonly=on,boot.order=1 \
--disk "$VIRTIO_ISO",device=cdrom,bus=usb,readonly=on \
--os-variant win11 \
--arch aarch64 \
--boot uefi,firmware.feature0.name=secure-boot,firmware.feature0.enabled=yes \
--tpm backend.type=emulator,backend.version=2.0,model=tpm-tis \
--network bridge=br0,model=virtio \
--graphics vnc,listen=127.0.0.1 \
--video model=ramfb \
--noautoconsole
This build's pool:
Name | vCPU | RAM (MB) | Disk |
|---|---|---|---|
flang-arm64-windows-msvc | 8 | 65536 | 256 |
lldb-aarch64-windows | 8 | 32768 | 256 |
clang-arm64-windows-msvc | 16 | 32768 | 256 |
clang-arm64-windows-msvc-2stage | 32 | 65536 | 256 |
If --video model=ramfb is rejected
Create without it, then virsh edit <vm> and replace the entire <video> block:
<video>
<model type='ramfb' primary='yes'/>
</video>
Delete the <address> and <alias> lines — ramfb is not a PCI device and cannot take a PCI address. virt-xml --edit --video model=ramfb fails because it changes the model but leaves the stale address behind.
5. Fixed VNC ports
Assign explicitly so the VM-to-port mapping survives restarts:
virt-xml flang-arm64-windows-msvc --edit --graphics vnc,port=5900,listen=127.0.0.1
virt-xml lldb-aarch64-windows --edit --graphics vnc,port=5901,listen=127.0.0.1
virt-xml clang-arm64-windows-msvc --edit --graphics vnc,port=5902,listen=127.0.0.1
virt-xml clang-arm64-windows-msvc-2stage --edit --graphics vnc,port=5903,listen=127.0.0.1
autoport is not a --graphics suboption. Verify it flipped to no:
for vm in flang-arm64-windows-msvc lldb-aarch64-windows \
clang-arm64-windows-msvc clang-arm64-windows-msvc-2stage; do
echo "$vm: $(virsh dumpxml $vm | grep -o "port='[0-9]*' autoport='[a-z]*'")"
done
If any still shows autoport='yes', set it by hand in virsh edit — that attribute overrides your fixed port. A VM left on autoport can also squat on a port you intended for another VM, producing Failed to find an available port: Address already in use. Diagnose with sudo ss -tlnp | grep -E '590[0-9]'.
Adding a fifth VM later: assign 5904+ explicitly. libvirt will not route around your fixed ports.
6. macOS/Linux workstation access
~/.ssh/config:
Host <KVM hostname>
HostName <KVM host IP>
User <user>
IdentityFile <your SSH private key>
LocalForward 15900 localhost:5900
LocalForward 15901 localhost:5901
LocalForward 15902 localhost:5902
LocalForward 15903 localhost:5903
ExitOnForwardFailure yes
Local ports are deliberately not 5900–5903: macOS Screen Sharing refuses loopback on 5900 with "Cannot control your own screen."
On the Mac, use TigerVNC, not Apple's Screen Sharing — the latter negotiates security types poorly against QEMU's no-auth VNC server and prompts for a password that does not exist.
brew install --cask tigervnc
Then ssh <KVM hostname> and connect via VNC to localhost:15900–15903.
If SSH reports Address already in use with nothing in lsof, check for a stale ControlMaster socket in /private/tmp/ and delete it.
7. Windows installation (per VM)
Expect half an hour per VM. Connect VNC before or immediately as the VM starts — the "Press any key to boot from CD or DVD" prompt times out in a few seconds and falls through to PXE.
Press a key at the CD prompt.
Setup jumps to Install driver to show hardware — expected; the virtio disk is invisible.
Browse to
D:\vioscsi\w11\ARM64(the USB-attached virtio-win CD). Install.vioscsi= virtio-scsi controller.viostor= virtio-blk. With the single-controller layout above you need onlyvioscsi.
The 256 GB disk appears. Proceed with installation.
At the mid-install reboot, do not press a key at the CD prompt — let it time out so it boots from disk.
Do not configure internet in the first boot. Select “I don’t have internet” and create a regular user. Failing to do so will make the use of a Microsoft Account mandatory.
After you reach the desktop for the first time, open the File Manager, browse to
D:\NetKVM\w11\ARM64and install the driver (right-click the.infand select Install. You will notice that the network icon in the system tray will go live. Then, go to the Control Panel, Network Settings and configure the adapter to the network IP/Gateway/DNS you want.Next, go to Settings and Remote Desktop Configuration and activate Remote Desktop. From then on, you may use RDC/Windows App to connect to the VM directly.
Open Settings one last time, click Activate Windows. Then Change Product Key and type the Linaro VLK.
Take the first VM all the way to a working desktop and a clean reboot before starting the others. Once one is proven, the rest can run in parallel.
If Setup complains the PC cannot run Windows 11: Shift+F10 → regedit → create HKLM\SYSTEM\Setup\LabConfig with DWORD values BypassTPMCheck, BypassSecureBootCheck, BypassRAMCheck, BypassStorageCheck, BypassCPUCheck set to 1. With TPM 2.0 and Secure Boot configured as above this should not trigger.
8. Teardown — safe commands
Never use --remove-all-storage. Delete the VM's own disk by path:
virsh destroy <vm> 2>/dev/null
virsh undefine <vm> --nvram
rm -f /var/lib/libvirt/images/<vm>.qcow2
--nvram clears the per-VM UEFI variables. Without it, stale boot entries carry into the rebuild and produce 0xc000000d / missing-BCD errors on the fresh install.
Never virsh destroy a VM mid-installation. A hard power-off during Windows Setup's file-copy/boot-configuration stage leaves an ESP and a boot entry with an incomplete BCD. All four VMs were lost this way on the first build. Check installer progress before any destructive operation.
9. Known-good minimal reproducer
When a VM fails to boot and the cause is unclear, this strips out libvirt, Secure Boot, and TPM in one step. If it reaches Setup, the problem is in the domain config; if it fails identically, the problem is firmware/QEMU/media.
qemu-system-aarch64 \
-M virt -accel kvm -cpu host -m 4G -smp 4 \
-bios /usr/share/AAVMF/AAVMF_CODE.no-secboot.fd \
-device ramfb \
-device qemu-xhci -device usb-kbd -device usb-tablet \
-device virtio-scsi-pci,id=scsi \
-drive file=/var/lib/libvirt/isos/<windows>.ISO,media=cdrom,if=none,id=install \
-device scsi-cd,drive=install,bus=scsi.0 \
-vnc 127.0.0.1:10 -S
-S starts paused so you can attach VNC before the boot prompt races past. Unpause from the QEMU monitor.
10. Diagnostics reference
# Is the guest doing I/O or genuinely hung?
virsh domstats <vm> --block | grep rd.bytes # sample twice, 20s apart
# What did QEMU actually get told to do?
sudo cat /proc/$(pgrep -f "qemu-system-aarch64.*<vm>")/cmdline | tr '\0' '\n'
# Startup errors
sudo tail -30 /var/log/libvirt/qemu/<vm>.log
# Honest disk read (drop cache first — otherwise you measure page cache)
sudo sysctl -w vm.drop_caches=3
dd if=<file> of=/dev/null bs=4M status=progress