This post walks through a small, direct libvirt workflow for installing
Windows Server from an ISO, enabling SSH during first boot, capturing the result
as a reusable qcow2 base image, and creating disposable VM instances from that
base.
The examples use libvirt’s QEMU backend through qemu:///system, driven by
virt-install and virsh.
The example uses Windows Server 2022 Evaluation, but the shape of the workflow
is not tied to that particular release. The moving parts are plain Windows
unattended setup, a first-boot PowerShell script, virt-install, and qcow2
overlays.
The goal is intentionally modest: one local Windows Server image that can be
used to create disposable VMs quickly, without introducing an image-factory
framework.
This is a local development workflow. The example password is intentionally
visible because it is baked into the unattended install file; change it before
using the process anywhere less disposable.
Layout
The workspace is rooted at:
The final workspace looks like this:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 ~/vms/ |-- README.md |-- isos/ | |-- windows-server-2022-eval.iso | `-- virtio-win.iso |-- autoinstall/ | |-- Autounattend.xml | |-- first-boot.ps1 | `-- autoinstall.iso |-- scripts/ | |-- winvm-build-autoinstall-iso | |-- winvm-build-ssh-base | |-- winvm-create | `-- winvm-ip |-- images/ | `-- windows-server-2022-ssh-base.qcow2 `-- instances/ `-- <disposable-vm>.qcow2
The flow is simple: the installer ISOs plus the autoinstall files produce a base
image under images/; disposable VMs are qcow2 overlays under instances/.
The base image produced by this process has:
1 2 3 4 5 User: Administrator Password: qwf123!@# SSH: enabled, password auth Disk bus: SATA NIC model: e1000e
No host SSH public key is baked into the image. SSH access uses the
Administrator password unless you add your own key later.
1. Download The ISOs
The host needs a working libvirt/QEMU setup with virt-install, virsh,
qemu-img, and xorriso available.
Create an ISO directory:
Download the Windows Server evaluation ISO from Microsoft:
1 https://www.microsoft.com/en-us/evalcenter/download-windows-server-2022
Save it as:
1 ~/vms/isos/windows-server-2022-eval.iso
Download the Fedora virtio-win driver/tools ISO:
1 https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/stable-virtio/virtio-win.iso
Save it as:
1 ~/vms/isos/virtio-win.iso
The copy used while testing this post was:
1 2 virtio-win-0.1.285 sha256=e14cf2b94492c3e925f0070ba7fdfedeb2048c91eea9c5a5afb30232a3976331
The VM deliberately uses SATA storage and an e1000e NIC so Windows can install
and obtain network access without needing VirtIO drivers during setup. The
virtio ISO is still attached because first-boot.ps1 installs
virtio-win-guest-tools.exe after Windows has booted.
2. Prepare The Autoinstall Files
The unattended install is driven by:
1 ~/vms/autoinstall/Autounattend.xml
This file answers the Windows setup prompts, partitions the disk, sets the
Administrator password, enables autologon, and runs a first-logon command:
1 powershell.exe ... first-boot.ps1
The first-boot script is:
1 ~/vms/autoinstall/first-boot.ps1
That script is the guest-side provisioning entrypoint. It does three things:
Installs VirtIO guest tools if virtio-win-guest-tools.exe is present.
Installs and starts OpenSSH Server.
Writes readiness markers under C:\setup.
The readiness markers are:
1 2 C:\setup\ssh-ready.txt C:\setup\ready.txt
3. Build The Autoinstall ISO
Bundle the unattended setup files into a small ISO:
1 ~/vms/scripts/winvm-build-autoinstall-iso
The command creates:
1 ~/vms/autoinstall/autoinstall.iso
The ISO intentionally contains only two files:
1 2 Autounattend.xml first-boot.ps1
4. Install Windows And Provision SSH
Start a fresh Windows install VM:
1 ~/vms/scripts/winvm-build-ssh-base
The script creates a new disk:
1 ~/vms/instances/win2022-ssh-base-build.qcow2
and starts a libvirt VM named:
The build VM uses:
1 2 3 4 5 machine: q35 firmware: UEFI disk: SATA qcow2 network: e1000e on libvirt default network graphics: SPICE/qxl
The script attaches three CD-ROMs to the VM:
1 2 3 Windows Server 2022 ISO autoinstall.iso virtio-win.iso
It also sends Enter keypresses for the Windows ISO boot prompt:
1 Press any key to boot from CD or DVD
Without those keypresses, a headless install can sit at that prompt forever.
Windows setup may power off once during installation. If that happens, start the
same VM again and let first-boot provisioning continue:
1 virsh -c qemu:///system start win2022-ssh-base-build
A typical full build takes about 15-30 minutes, depending on host load and
Windows setup timing.
5. Verify SSH
Once first-boot provisioning has finished, get the VM IP:
1 2 ip=$(~/vms/scripts/winvm-ip win2022-ssh-base-build) echo "$ip "
Verify SSH with a real login:
1 ssh Administrator@"$ip " hostname
Password:
Expected output:
6. Capture The Base Image
After SSH works, shut down the build VM:
1 virsh -c qemu:///system shutdown win2022-ssh-base-build
After it is shut off, undefine the temporary libvirt domain and move the disk
into images:
1 2 3 virsh -c qemu:///system undefine win2022-ssh-base-build --nvram mv ~/vms/instances/win2022-ssh-base-build.qcow2 \ ~/vms/images/windows-server-2022-ssh-base.qcow2
If you are replacing an existing base image, move the old file aside first.
That qcow2 file is now the reusable base image.
7. Create A Disposable VM From The Base
Create and start a disposable VM from the base image:
1 ~/vms/scripts/winvm-create ssh-test
This creates a qcow2 overlay:
1 ~/vms/instances/ssh-test.qcow2
It also defines the libvirt domain and starts the VM immediately. You do not
need a separate virsh start after winvm-create.
You can see the running VM with:
1 virsh -c qemu:///system list --all
The overlay is backed by the base image:
1 ~/vms/images/windows-server-2022-ssh-base.qcow2
Get the VM IP:
1 2 ip=$(~/vms/scripts/winvm-ip ssh-test) echo "$ip "
Connect with SSH:
8. Clean Up A Disposable VM
Gracefully shut down when possible:
1 virsh -c qemu:///system shutdown ssh-test
Force power off only if needed:
1 virsh -c qemu:///system destroy ssh-test
Delete the disposable VM and its overlay disk:
1 2 virsh -c qemu:///system undefine ssh-test --nvram rm -f ~/vms/instances/ssh-test.qcow2
Appendix: Source Files
These are the complete source files used by the workflow above. Paths are relative to ~/vms.
autoinstall/Autounattend.xml
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 <?xml version="1.0" encoding="utf-8" ?> <unattend xmlns ="urn:schemas-microsoft-com:unattend" > <settings pass ="windowsPE" > <component name ="Microsoft-Windows-International-Core-WinPE" processorArchitecture ="amd64" publicKeyToken ="31bf3856ad364e35" language ="neutral" versionScope ="nonSxS" xmlns:wcm ="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" > <SetupUILanguage > <UILanguage > en-US</UILanguage > </SetupUILanguage > <InputLocale > en-US</InputLocale > <SystemLocale > en-US</SystemLocale > <UILanguage > en-US</UILanguage > <UserLocale > en-US</UserLocale > </component > <component name ="Microsoft-Windows-Setup" processorArchitecture ="amd64" publicKeyToken ="31bf3856ad364e35" language ="neutral" versionScope ="nonSxS" xmlns:wcm ="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" > <DiskConfiguration > <Disk wcm:action ="add" > <DiskID > 0</DiskID > <WillWipeDisk > true</WillWipeDisk > <CreatePartitions > <CreatePartition wcm:action ="add" > <Order > 1</Order > <Type > EFI</Type > <Size > 100</Size > </CreatePartition > <CreatePartition wcm:action ="add" > <Order > 2</Order > <Type > MSR</Type > <Size > 16</Size > </CreatePartition > <CreatePartition wcm:action ="add" > <Order > 3</Order > <Type > Primary</Type > <Extend > true</Extend > </CreatePartition > </CreatePartitions > <ModifyPartitions > <ModifyPartition wcm:action ="add" > <Order > 1</Order > <PartitionID > 1</PartitionID > <Format > FAT32</Format > <Label > System</Label > </ModifyPartition > <ModifyPartition wcm:action ="add" > <Order > 2</Order > <PartitionID > 3</PartitionID > <Format > NTFS</Format > <Label > Windows</Label > <Letter > C</Letter > </ModifyPartition > </ModifyPartitions > </Disk > <WillShowUI > OnError</WillShowUI > </DiskConfiguration > <ImageInstall > <OSImage > <InstallFrom > <MetaData wcm:action ="add" > <Key > /IMAGE/INDEX</Key > <Value > 2</Value > </MetaData > </InstallFrom > <InstallTo > <DiskID > 0</DiskID > <PartitionID > 3</PartitionID > </InstallTo > <WillShowUI > OnError</WillShowUI > </OSImage > </ImageInstall > <UserData > <AcceptEula > true</AcceptEula > <FullName > Administrator</FullName > <Organization > OpenJDK</Organization > </UserData > </component > </settings > <settings pass ="specialize" > <component name ="Microsoft-Windows-Shell-Setup" processorArchitecture ="amd64" publicKeyToken ="31bf3856ad364e35" language ="neutral" versionScope ="nonSxS" xmlns:wcm ="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" > <ComputerName > WIN-OJDK-BASE</ComputerName > <TimeZone > UTC</TimeZone > </component > <component name ="Microsoft-Windows-TerminalServices-LocalSessionManager" processorArchitecture ="amd64" publicKeyToken ="31bf3856ad364e35" language ="neutral" versionScope ="nonSxS" xmlns:wcm ="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" > <fDenyTSConnections > false</fDenyTSConnections > </component > <component name ="Microsoft-Windows-ServerManager-SvrMgrNc" processorArchitecture ="amd64" publicKeyToken ="31bf3856ad364e35" language ="neutral" versionScope ="nonSxS" xmlns:wcm ="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" > <DoNotOpenServerManagerAtLogon > true</DoNotOpenServerManagerAtLogon > </component > </settings > <settings pass ="oobeSystem" > <component name ="Microsoft-Windows-International-Core" processorArchitecture ="amd64" publicKeyToken ="31bf3856ad364e35" language ="neutral" versionScope ="nonSxS" xmlns:wcm ="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" > <InputLocale > en-US</InputLocale > <SystemLocale > en-US</SystemLocale > <UILanguage > en-US</UILanguage > <UserLocale > en-US</UserLocale > </component > <component name ="Microsoft-Windows-Shell-Setup" processorArchitecture ="amd64" publicKeyToken ="31bf3856ad364e35" language ="neutral" versionScope ="nonSxS" xmlns:wcm ="http://schemas.microsoft.com/WMIConfig/2002/State" xmlns:xsi ="http://www.w3.org/2001/XMLSchema-instance" > <OOBE > <HideEULAPage > true</HideEULAPage > <HideLocalAccountScreen > true</HideLocalAccountScreen > <HideOEMRegistrationScreen > true</HideOEMRegistrationScreen > <HideOnlineAccountScreens > true</HideOnlineAccountScreens > <HideWirelessSetupInOOBE > true</HideWirelessSetupInOOBE > <NetworkLocation > Work</NetworkLocation > <ProtectYourPC > 3</ProtectYourPC > </OOBE > <UserAccounts > <AdministratorPassword > <Value > qwf123!@#</Value > <PlainText > true</PlainText > </AdministratorPassword > </UserAccounts > <AutoLogon > <Enabled > true</Enabled > <Username > Administrator</Username > <LogonCount > 5</LogonCount > <Password > <Value > qwf123!@#</Value > <PlainText > true</PlainText > </Password > </AutoLogon > <FirstLogonCommands > <SynchronousCommand wcm:action ="add" > <Order > 1</Order > <Description > Run first boot provisioning</Description > <CommandLine > powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "foreach ($d in 'D','E','F','G','A') { $p = $d + ':\first-boot.ps1'; if (Test-Path $p) { & $p; break } }"</CommandLine > <RequiresUserInput > false</RequiresUserInput > </SynchronousCommand > </FirstLogonCommands > </component > </settings > </unattend >
autoinstall/first-boot.ps1
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 $ErrorActionPreference = 'Continue' New-Item -ItemType Directory -Force C:\setup | Out-Null foreach ($drive in Get-PSDrive -PSProvider FileSystem) { $installer = Join-Path $drive .Root 'virtio-win-guest-tools.exe' if (Test-Path $installer ) { Start-Process -FilePath $installer -ArgumentList '/quiet' , '/norestart' -Wait break } } $oldErrorActionPreference = $ErrorActionPreference $ErrorActionPreference = 'Stop' try { try { Add-WindowsCapability -Online -Name OpenSSH.Server~~~~0.0 .1.0 } catch { Write-Warning "OpenSSH capability install failed or was already installed: $_ " } Start-Service sshd Set-Service -Name sshd -StartupType Automatic if (-not (Get-NetFirewallRule -Name sshd -ErrorAction SilentlyContinue)) { New-NetFirewallRule -Name sshd -DisplayName 'OpenSSH Server' -Enabled True -Direction Inbound -Protocol TCP -Action Allow -LocalPort 22 } $sshdConfig = 'C:\ProgramData\ssh\sshd_config' if (Test-Path $sshdConfig ) { $content = Get-Content $sshdConfig $content = $content -replace '^#?PasswordAuthentication\s+.*$' , 'PasswordAuthentication yes' $content = $content -replace '^#?PubkeyAuthentication\s+.*$' , 'PubkeyAuthentication yes' $content | Set-Content $sshdConfig } try { Restart-Service sshd -Force } catch {} Set-Content C:\setup\ssh-ready .txt (Get-Date -Format o) } catch { Set-Content C:\setup\ssh-error .txt $_ throw } finally { $ErrorActionPreference = $oldErrorActionPreference } try { New-Item -ItemType Directory -Force 'C:\ProgramData\Microsoft\Windows\Start Menu\Programs\StartUp.disabled' | Out-Null New-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -PropertyType DWORD -Value 1 -Force | Out-Null } catch {} Set-Content C:\setup\ready.txt (Get-Date -Format o)
scripts/winvm-build-autoinstall-iso
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 #!/usr/bin/env bash set -euo pipefailscript_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]} " ) " && pwd ) root=$(cd -- "${script_dir} /.." && pwd ) out=${1:-${root} /autoinstall/autoinstall.iso} WORK=$(mktemp -d) trap 'rm -rf "${WORK}"' EXITcp "${root} /autoinstall/first-boot.ps1" "${WORK} /first-boot.ps1" cp "${root} /autoinstall/Autounattend.xml" "${WORK} /Autounattend.xml" xorriso -as mkisofs -iso-level 3 -J -r -V AUTOINSTALL -o "${out} " "${WORK} " >/dev/null echo "${out} "
scripts/winvm-build-ssh-base
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 #!/usr/bin/env bash set -euo pipefailLIBVIRT_URI=${LIBVIRT_URI:-qemu:///system} script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]} " ) " && pwd ) VM_ROOT=${VM_ROOT:-$(cd -- "${script_dir} /.." && pwd)} ISO_DIR=${ISO_DIR:-${VM_ROOT} /isos} INSTANCES_DIR=${INSTANCES_DIR:-${VM_ROOT} /instances} die () { echo "error: $*" >&2 exit 1 } name=${1:-win2022-ssh-base-build} disk=${INSTANCES_DIR} /${name} .qcow2 win_iso=${ISO_DIR} /windows-server-2022-eval.iso virtio_iso=${ISO_DIR} /virtio-win.iso autoinstall_iso=${AUTOINSTALL_ISO:-${VM_ROOT} /autoinstall/autoinstall.iso} [[ -f "${win_iso} " ]] || die "missing Windows ISO: ${win_iso} " [[ -f "${virtio_iso} " ]] || die "missing virtio ISO: ${virtio_iso} " [[ -f "${autoinstall_iso} " ]] || die "missing autoinstall ISO: ${autoinstall_iso} " if virsh -c "${LIBVIRT_URI} " dominfo "${name} " >/dev/null 2>&1; then die "VM already exists: ${name} " fi [[ ! -e "${disk} " ]] || die "disk already exists: ${disk} " mkdir -p "${INSTANCES_DIR} " qemu-img create -f qcow2 "${disk} " 80G env PYTHONNOUSERSITE=1 PATH="/usr/sbin:/usr/bin:/sbin:/bin" /usr/bin/python3 /usr/bin/virt-install \ --connect "${LIBVIRT_URI} " \ --name "${name} " \ --memory "${WINVM_MEMORY:-12288} " \ --vcpus "${WINVM_CPUS:-6} " \ --cpu host-passthrough \ --machine q35 \ --os-variant win2k22 \ --boot uefi \ --disk "path=${disk} ,format=qcow2,bus=sata,cache=none,discard=unmap" \ --disk "path=${autoinstall_iso} ,device=cdrom" \ --disk "path=${virtio_iso} ,device=cdrom" \ --cdrom "${win_iso} " \ --network "network=default,model=e1000e" \ --graphics spice \ --video qxl \ --channel spicevmc \ --noautoconsole for _ in 1 2 3; do sleep 2 virsh -c "${LIBVIRT_URI} " send-key "${name} " KEY_ENTER >/dev/null 2>&1 || true done cat <<EOF ${name} The unattended Windows install is now running. Typical elapsed time is about 15-30 minutes, depending on host load and Windows setup timing. This script already sends Enter keypresses for the Windows ISO prompt: Press any key to boot from CD or DVD During setup the VM may power off after an installation phase. If that happens, start it again and let Windows continue first-boot provisioning: virsh -c ${LIBVIRT_URI} start ${name} Wait for installation and first-boot provisioning to finish, then verify SSH: ${script_dir}/winvm-ip ${name} ssh Administrator@"\$(${script_dir}/winvm-ip ${name})" hostname Readiness markers inside the guest are: C:\setup\ssh-ready.txt C:\setup\ready.txt Administrator password: qwf123!@# EOF
scripts/winvm-create
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 #!/usr/bin/env bash set -euo pipefailLIBVIRT_URI=${LIBVIRT_URI:-qemu:///system} script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]} " ) " && pwd ) VM_ROOT=${VM_ROOT:-$(cd -- "${script_dir} /.." && pwd)} IMAGES_DIR=${IMAGES_DIR:-${VM_ROOT} /images} INSTANCES_DIR=${INSTANCES_DIR:-${VM_ROOT} /instances} usage () { cat >&2 <<'EOF' usage: winvm-create <vm-name> [base-image-name-or-path] [disk-size] Defaults: base image: windows-server-2022-ssh-base.qcow2 disk bus: sata NIC model: e1000e Override devices with WINVM_DISK_BUS=... and WINVM_NIC_MODEL=... EOF exit 2 } die () { echo "error: $*" >&2 exit 1 } [[ $# -ge 1 && $# -le 3 ]] || usage name=$1 base=${2:-windows-server-2022-ssh-base.qcow2} disk_size=${3:-} disk_bus=${WINVM_DISK_BUS:-sata} nic_model=${WINVM_NIC_MODEL:-e1000e} [[ "${base} " = /* ]] || base="${IMAGES_DIR} /${base} " [[ -f "${base} " ]] || die "base image not found: ${base} " if virsh -c "${LIBVIRT_URI} " dominfo "${name} " >/dev/null 2>&1; then die "VM already exists: ${name} " fi mkdir -p "${INSTANCES_DIR} " disk="${INSTANCES_DIR} /${name} .qcow2" [[ ! -e "${disk} " ]] || die "disk already exists: ${disk} " qemu-img create -f qcow2 -F qcow2 -b "${base} " "${disk} " ${disk_size:+"${disk_size} "} env PYTHONNOUSERSITE=1 PATH="/usr/sbin:/usr/bin:/sbin:/bin" /usr/bin/python3 /usr/bin/virt-install \ --connect "${LIBVIRT_URI} " \ --name "${name} " \ --memory "${WINVM_MEMORY:-12288} " \ --vcpus "${WINVM_CPUS:-6} " \ --cpu host-passthrough \ --machine q35 \ --os-variant win2k22 \ --boot uefi \ --disk "path=${disk} ,format=qcow2,bus=${disk_bus} ,cache=none,discard=unmap" \ --network "network=default,model=${nic_model} " \ --graphics spice \ --video qxl \ --channel spicevmc \ --import \ --noautoconsole echo "${name} "
scripts/winvm-ip
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 #!/usr/bin/env bash set -euo pipefailLIBVIRT_URI=${LIBVIRT_URI:-qemu:///system} die () { echo "error: $*" >&2 exit 1 } [[ $# -eq 1 ]] || die "usage: winvm-ip <vm-name>" name=$1 mac=$(virsh -c "${LIBVIRT_URI} " domiflist "${name} " \ | awk '$2 == "network" { mac=$5 } END { print mac }' ) [[ -n "${mac} " ]] || exit 1 ip=$(virsh -c "${LIBVIRT_URI} " net-dhcp-leases default \ | awk -v mac="${mac} " '$0 ~ mac { ip=$5 } END { sub(/\/.*$/, "", ip); print ip }' ) [[ -n "${ip} " ]] || exit 1 echo "${ip} "