Illustration of a build pipeline: sysprep answer files and bootstrap.ps1 feed a conveyor that turns Windows Server installs into generalized images, which flow into SUSE Virtualization

TL;DR

You need three things:

  1. A Windows Server ISO, uploaded under Images. Server 2022 and 2025 are tested, and evaluation ISOs work.
  2. A ready-made answer file for your version: Server 2022 or Server 2025.
  3. SUSE Virtualization 1.9, whose VM create form has a Windows Unattended & Sysprep section once the VM’s OS type is Windows.

Then:

  1. Create a VM from the windows-iso-medium-template template and attach your ISO.
  2. Set the root disk to 36 GiB.
  3. In Advanced Options, set OS Type to Windows. That reveals the Windows Unattended & Sysprep Configuration section: choose Create New, paste the answer file and create the VM.
  4. About 20 minutes later the VM turns itself off. Its disk is your golden image: export it to an image (or shrink it first) and create Windows VMs from it with a cloud-config.

SUSE Virtualization 1.9.1 adds a windows-image-template that simplifies that last step. Everything below explains what happens under the hood and the other ways to build.

How it all fits together

A Linux cloud image on SUSE Virtualization (Harvester) personalizes itself on first boot: you pick the image, add a cloud-config, and the VM comes up with its own hostname, users and disk size. My harvester-windows-cloudbase project gives Windows the same experience. You start from a stock Windows ISO, and an unattended build produces a sysprepped image that any number of VMs can clone.

This post explains each sysprep file in the repo, which one to pick, and the full path from ISO to running VM.

What the image contains

  • SUSE VMDP (Virtual Machine Driver Pack): signed virtio drivers for disk and network, plus the QEMU guest agent.
  • Cloudbase-Init, the Windows counterpart of cloud-init. It reads the cloud-config that SUSE Virtualization attaches to each VM and applies the hostname, users, passwords and runcmd on first boot.
  • A generalized install. sysprep /generalize removes the machine identity, so every clone gets its own.
  • Automatic disk growth. Cloudbase-Init’s ExtendVolumesPlugin grows C: to the VM’s disk, so one small image serves any disk size.

How the build works

A build VM boots with four devices: the Windows ISO, the VMDP driver CD, a sysprep CD holding the answer file, and an empty root disk. From there everything is unattended:

  1. windowsPE pass. Windows Setup finds autounattend.xml on the sysprep CD, loads the virtio disk drivers from the VMDP CD, partitions the root disk and installs the edition named in the answer file.
  2. specialize and oobeSystem passes. Setup configures the machine, logs on as Administrator and runs the answer file’s FirstLogonCommands.
  3. Bootstrap. FirstLogonCommands starts bootstrap.ps1, which freezes Windows Update, the Store and Edge Update, installs VMDP and Cloudbase-Init, deletes build files and zeroes free space. It logs to C:\winbuild.log.
  4. Generalize. The script runs sysprep /generalize /oobe /shutdown with Cloudbase-Init’s own unattend file, which re-arms Cloudbase-Init for the next boot. The VM powers off after about 20 minutes.

That powered-off root disk is the golden image. You register it as a SUSE Virtualization image and delete the build VM.

KubeVirt’s sysprep volume provides the sysprep CD: it turns a Kubernetes Secret into an ISO, and every key in the Secret becomes a file on that ISO.

Pick your answer file

All files live in the repo’s kubectl/ folder. There are two layouts:

  • Two-file: a base answer file plus bootstrap.ps1 as two keys in the Secret. FirstLogonCommands searches the CD drives for the script. Use this with kubectl or Terraform.
  • Self-contained: a single autounattend.xml with bootstrap.ps1 embedded in it as base64. SUSE Virtualization 1.9’s UI writes exactly one key, so this is the layout for the UI.
FileLayoutWindowsStatus
Autounattend-2022.xmltwo-file (with bootstrap.ps1)Server 2022tested
Autounattend-2025.xmltwo-file (with bootstrap.ps1)Server 2025tested
Autounattend-w11.xmltwo-file (with bootstrap.ps1)Windows 11experimental
Autounattend-selfcontained-2022.xmlself-containedServer 2022tested
Autounattend-selfcontained-2025.xmlself-containedServer 2025tested
Autounattend-selfcontained-w11.xmlself-containedWindows 11experimental
Autounattend-selfcontained-coriolis-2022.xmlself-containedServer 2022tested
Autounattend-selfcontained-coriolis-2025.xmlself-containedServer 2025tested

Two more files complete the set:

  • bootstrap.ps1 is the first-logon script. It’s the same for every version.
  • build-answerfile.py builds a self-contained file from a base answer file and bootstrap.ps1. You need it only after editing either one, or to change the edition.

Windows 11 is experimental because sysprep hangs on Sysprep_Clean_Validate_Opk in about half of my runs; the install itself completes. Use the coriolis files to build a worker image for Coriolis migrations into SUSE Virtualization: they add a WinRM HTTPS listener and make Cloudbase-Init create the user that Coriolis sends. A real VMware-to-SUSE Virtualization migration passed its worker login with these images. Use the plain files for everything else.

Match the edition to your ISO

Each answer file names the Windows edition to install, for example Windows Server 2022 SERVERSTANDARD. Setup compares that string byte for byte with the images in the ISO’s sources\install.wim. If it doesn’t match, Setup shows the edition picker and waits for a click that never comes, and the build VM just keeps running.

Retail and evaluation ISOs use the same names, so the defaults work for both. For Datacenter, generate a new file:

$ cd kubectl/
$ ./build-answerfile.py -w 2022 --edition 'Windows Server 2022 SERVERDATACENTER'
wrote kubectl/Autounattend-selfcontained-2022.xml (flavor: standard)
  bootstrap.ps1: 19837 bytes -> 26452 base64 chars in 38 chunks
  longest CommandLine: 749 chars (limit 1024)

To list the editions in your own ISO, run wiminfo sources/install.wim on Linux or dism /Get-WimInfo /WimFile:D:\sources\install.wim on Windows. Server Core isn’t supported, because bootstrap.ps1 assumes the Desktop Experience.

Watch the last line of that output. Windows rejects the answer file if any FirstLogonCommands command line is longer than 1024 characters, so the generator splits the embedded script into chunks and checks the limit.

Before you start

  • SUSE Virtualization 1.9 for the UI path; 1.8 works with kubectl or Terraform.
  • A Windows Server 2022 or 2025 ISO uploaded under Images.
  • A StorageClass you trust for VM disks. harvester-longhorn exists on every cluster.

Find your ISO’s short name and its own StorageClass:

kubectl get virtualmachineimage -A -o custom-columns=\
'NS:.metadata.namespace,NAME:.metadata.name,DISPLAY:.spec.displayName,SC:.status.storageClassName'

SUSE Virtualization gives every image a dedicated StorageClass, and the build VM’s CD-ROM volume has to use that class; with any other class, the ISO clone comes up empty and the VM stops at “No Bootable Device”.

Build from the UI

SUSE Virtualization 1.9 adds a Windows Unattended & Sysprep section to the VM create form.

  1. Go to Virtual Machines → Create, and choose the windows-iso-medium-template template. It ships with 1.9 and already has the boot CD-ROM, the VMDP driver CD, a virtio root disk and the Hyper-V enlightenments described in Windows VMs on SUSE Virtualization.
  2. On the CD-ROM disk, set Image to your Windows ISO.
  3. Shrink the root disk to 36 GiB. An exported image keeps the build disk’s size, and C: grows when a VM is deployed, so build small.
  4. Under Advanced Options, set OS Type to Windows. Only then does the Windows Unattended & Sysprep Configuration section appear.
  5. Choose Create New and paste the whole content of Autounattend-selfcontained-<version>.xml.
  6. Click Create, then wait for the VM to stop by itself.

Build with kubectl

With the two-file layout, bootstrap.ps1 stays readable in the Secret:

cd kubectl/
kubectl create secret generic winbuild-unattend \
  --from-file=autounattend.xml=Autounattend-2022.xml \
  --from-file=bootstrap.ps1=bootstrap.ps1

# Edit winbuild-vm.yaml first: ISO image, ISO StorageClass, root disk StorageClass
kubectl apply -f winbuild-vm.yaml
kubectl wait --for=jsonpath='{.status.printableStatus}'=Stopped \
  vm/winbuild --timeout=45m

For automation, the repo’s terraform/ folder runs the same build with one terraform apply and a windows_version variable (2022, 2025 or w11).

Capture the image

Register the stopped root disk as an image:

apiVersion: harvesterhci.io/v1beta1
kind: VirtualMachineImage
metadata:
  name: win2022
  namespace: default
spec:
  displayName: win2022
  backend: cdi
  sourceType: export-from-volume
  pvcName: winbuild-rootdisk
  pvcNamespace: default
  targetStorageClassName: harvester-longhorn

Two details:

  • Always set targetStorageClassName. Without it, the image silently lands on the cluster’s default StorageClass.
  • Keep displayName short and without spaces. SUSE Virtualization 1.9 copies it into a label, and labels can’t contain spaces.

This image keeps the full 36 GiB virtual size. For a smaller image, use the shrink Job in Extra: shrink the image instead of this export.

Once the image shows Imported, delete the build VM, its disks and the winbuild-unattend Secret.

Extra: shrink the image

An image has two sizes:

  • Virtual size: the disk a new VM starts with. It equals the build disk, 36 GiB.
  • Physical size: the data actually stored, and copied whenever a VM is created.

Smaller is better for both, because Cloudbase-Init grows C: on deploy anyway.

My repo’s shrink Job works on the stopped build disk and has two modes:

  • compact converts the disk to qcow2 and drops the zeroed free space. It only reduces the physical size, and it never changes the partitions.
  • shrink also resizes the NTFS file system on C: to its minimum plus 1 GiB of slack, shrinks the partition to match and cuts the virtual disk down to that size.

Either way, the Job then creates the image and uploads the qcow2 to SUSE Virtualization’s in-cluster upload API. No Rancher or SUSE Virtualization token is needed; the Job’s own ServiceAccount creates the image.

Run it

Run the Job before you delete the build disk, in the same namespace as that disk.

  1. Load the script as a ConfigMap:

    cd shrink-export/
    kubectl -n default create configmap disk-shrink-script \
      --from-file=entrypoint.sh=entrypoint.sh
    
  2. Edit shrink-and-upload-job.yaml. These are the values to set:

    env:
      - name: IMAGE_NAME
        value: win2022
      - name: IMAGE_DISPLAY
        value: win2022
      - name: BACKEND
        value: "cdi"                 # or "backingimage" for a Longhorn backing image
      - name: TARGET_STORAGECLASS
        value: "harvester-longhorn"  # cdi only: where the image is stored
      - name: MODE
        value: "shrink"              # or "compact"
    # ...
    volumes:
      - name: src
        persistentVolumeClaim:
          claimName: winbuild-rootdisk
    
  3. Apply it and follow the log:

    kubectl -n default apply -f shrink-and-upload-job.yaml
    kubectl -n default logs -f job/disk-shrink
    

This is the log from my Server 2022 build (trimmed):

[18:46:50] mode: shrink
[18:46:50] windows partition: /dev/mapper/loop8p2 (partition #2, start 525336576B)
[18:46:51] NTFS min=11654688768B, resizing filesystem to 12728664064B (+1024MiB slack)
[18:46:52] shrinking the partition table entry #2...
[18:46:52] converting to compact qcow2 at /work/disk.qcow2 ...
[18:51:28] shrinking qcow2 virtual size to 13255049216B ...
[18:51:28] creating VirtualMachineImage default/win2022 (backend=cdi, sourceType=upload, targetSC=px-csi-replicated)
[18:58:43]   HTTP 200
[18:58:46] done: VirtualMachineImage default/win2022

Shrinking took the image from 36 GiB to 12.3 GiB virtual and 9.27 GiB physical. Most of the 12 minutes went to the conversion and the upload; earlier runs of the same Job on Longhorn finished in about 3 minutes.

Before you shrink

  • Stop the build VM and leave it stopped. A running VM holds the disk the Job needs to attach.
  • Shrink only a disk that sysprep shut down cleanly. shrink refuses to touch an NTFS volume that isn’t consistent; compact is the safe choice for anything else.
  • Set TARGET_STORAGECLASS with cdi, or the image lands on the default class. CDI also stages the upload in a scratch volume on the default class, so that class needs free space too.
  • Storage that reports 4096-byte sectors is fine. Portworx volumes do, and the Job maps the disk through a 512-byte loop device automatically; see Portworx on SUSE Virtualization.

Deploy a VM from the image

Create a VM from the new image and give it a cloud-config:

#cloud-config
set_hostname: web01
users:
  - name: tux
    passwd: MyStr0ngPass!
    primary_group: Administrators
runcmd:
  - powershell.exe -Command "Install-WindowsFeature -Name Web-Server -IncludeManagementTools"

On first boot, Cloudbase-Init creates the user, runs the commands and extends C: to the VM’s disk. Hostname changes take effect after the next reboot, because the image doesn’t let Cloudbase-Init reboot the VM during first-boot setup (that caused boot loops).

If you create the cloud-config Secret yourself rather than through the UI, give it type: secret and the label harvesterhci.io/cloud-init-template: harvester. Cloudbase-Init doesn’t care, but the UI’s Cloud Config tab only shows Secrets that have both.

SUSE Virtualization 1.9.1 adds a windows-image-template for exactly this case: a single root disk from a prepared image, a virtio network card and a cloud-init disk. It’s in the 1.9.1 release candidates now.

Why the build freezes updates

sysprep /generalize fails with error 0x80073cf2 if any Microsoft Store app is installed for one user but not provisioned for all users. A fresh install is consistent, but the build VM has internet access. Windows Update, the Store and Edge Update can each update an app for the logged-on Administrator mid-build, and that breaks it.

bootstrap.ps1 therefore disables all three for the build, and the Server 2022 answer file already stops Edge Update and Windows Update in the specialize pass, before anyone logs on. A SetupComplete.cmd script that Windows runs once on every clone turns them back on, so deployed VMs still get updates.

Troubleshooting

SymptomCause and fix
“No Bootable Device”ISO volume used a generic StorageClass; use the image’s own class
Build VM keeps running at the edition pickerEdition string doesn’t match install.wim; regenerate with --edition
Error code: 0xD000A000 - 0x40031 (Server 2025)Setup didn’t load the virtio-scsi driver; use the repo’s answer files, which point at the exact VMDP folder
“The answer file is invalid” in oobeSystemA FirstLogonCommands line is over 1024 characters; regenerate with build-answerfile.py
0x80073cf2 in setuperr.logApp updated mid-build; rebuild from a fresh root disk with the current files
Deployed VM reboots in a loopOld image without allow_reboot=false in Cloudbase-Init’s config; rebuild
Cloud-config not shown in the UISecret lacks type: secret or the cloud-init-template label

Windows caches the answer file on the root disk, so after changing it, delete the root disk and build again; restarting the VM reuses the old copy. See the repo’s troubleshooting guide for each case in detail.

Takeaways

  • For the UI, paste Autounattend-selfcontained-<version>.xml; for kubectl, use the base file plus bootstrap.ps1.
  • Match the edition string to your ISO, and put the ISO volume on the image’s own StorageClass.
  • Build on a 36 GiB disk, set targetStorageClassName on export, and keep image names short.
  • Deploy with a cloud-config, and let Cloudbase-Init handle the hostname, users and disk size.