Skip to content

P02-T07 - Windows 11 Golden Image

Objective

Build the reusable Windows 11 workstation golden image only after the domain foundation is ready, so workstation join validation is performed against the same image standard that future customer deployments will use.

This runbook covers:

  • Proxmox VM creation for the transient Windows 11 image source
  • manual Windows installation through the console until QGA is available
  • QEMU Guest Agent, VirtIO guest tools, and PowerShell 7.6.3
  • deployment of a reusable Unattend.xml before capture
  • image validation through QGA
  • cleanup, DHCP reset, and Sysprep
  • clone into the low-range template VMID
  • conversion into the reusable workstation template

This runbook is an image-engineering exception to the normal Windows operations model in this guide. Here, QEMU Guest Agent is the primary transport after guest agent installation because the objective is repeatable image construction and validation, not day-to-day server or workstation administration.

For this phase, use QEMU Guest Agent as the authoritative execution channel as soon as it becomes available. Do not switch to RDP or WinRM as the primary build path.

Inputs

Key Value
Build VMID 4030
Final template VMID 4001
Template name TPL-W11E
Approved edition for this lab Windows 11 Enterprise LTSC
Supported editions in validation logic Windows 11 Pro, Windows 11 Enterprise, Windows 11 Enterprise LTSC
Storage local-zfs
Bridge GEILLAN
Build VLAN 110
Build IP 172.20.110.31/24
Build gateway 172.20.110.1
Build DNS 1.1.1.1, 9.9.9.9
Windows ISO Verify exact path on H1 before use
VirtIO ISO local:iso/virtio-win.iso
PowerShell target version 7.6.3

Target State

Property Value
Edition Windows 11 Enterprise LTSC for this lab
Firmware OVMF
Machine type q35
TPM v2.0 present
Secure Boot Enabled through enrolled keys
CPU 2 vCPU
Memory 4096 MB
Disk 64 GB
NIC VirtIO on GEILLAN tag 110
Guest agent Enabled
PowerShell 7.6.3
Domain joined No
Static IP retained in template No
Local build account retained as operator standard No
First cloned boot behavior completes business OOBE without manual console clicks

Prechecks

  • Complete P03-T01, P03-T02, P03-T03, and P03-T04.
  • Confirm QGA runner /root/gntech-qga/qga-run.sh already exists and works from earlier phases.
  • Confirm VLAN 110 build network is reachable and still intended for template construction.
  • Confirm the Windows 11 media is approved for this lab and licensing model.
  • Do not plan to launch Sysprep through QGA, qm guest exec, or any workflow that runs it as Local System.

If the runner is not already installed on H1, copy the complete repository version at this step:

Show full script: scripts/qga-run.sh
#!/usr/bin/env bash

set -Eeuo pipefail

usage() {
    cat <<'EOF'
Usage:
  qga-run.sh <VMID> <PowerShell-script.ps1> [timeout-seconds]

Example:
  qga-run.sh 4000 validate-golden-image.ps1 900
EOF
}

fail() {
    echo "ERROR: $*" >&2
    exit 1
}

[[ $# -ge 2 && $# -le 3 ]] || {
    usage
    exit 2
}

VMID="$1"
SCRIPT_FILE="$2"
TIMEOUT_SECONDS="${3:-900}"
POLL_SECONDS=5

command -v qm >/dev/null 2>&1 || fail "qm was not found."
command -v jq >/dev/null 2>&1 || fail "jq was not found. Install it with: apt install jq"
command -v iconv >/dev/null 2>&1 || fail "iconv was not found."
command -v base64 >/dev/null 2>&1 || fail "base64 was not found."

[[ -f "$SCRIPT_FILE" ]] || fail "PowerShell script not found: $SCRIPT_FILE"
[[ "$TIMEOUT_SECONDS" =~ ^[0-9]+$ ]] || fail "Timeout must be an integer."

qm status "$VMID" >/dev/null 2>&1 || fail "VMID $VMID does not exist."
[[ "$(qm status "$VMID" | awk '{print $2}')" == "running" ]] || fail "VMID $VMID is not running."
qm guest cmd "$VMID" ping >/dev/null 2>&1 || fail "QEMU Guest Agent is not responding for VMID $VMID."

ENCODED_COMMAND="$(iconv -f UTF-8 -t UTF-16LE "$SCRIPT_FILE" | base64 -w 0)"

START_RESPONSE="$(
    qm guest exec "$VMID" -- powershell.exe -NoLogo -NoProfile -NonInteractive \
        -ExecutionPolicy Bypass -EncodedCommand "$ENCODED_COMMAND"
)"

if jq -e '.exited == 1' >/dev/null 2>&1 <<<"$START_RESPONSE"; then
    FINAL_RESPONSE="$START_RESPONSE"
else
    PID="$(jq -r '.pid // empty' <<<"$START_RESPONSE")"
    [[ -n "$PID" ]] || fail "Unable to determine guest execution PID. Response: $START_RESPONSE"
    echo "Guest execution started with PID $PID."
    START_TIME="$(date +%s)"

    while true; do
        FINAL_RESPONSE="$(qm guest exec-status "$VMID" "$PID")"
        jq -e '.exited == 1' >/dev/null 2>&1 <<<"$FINAL_RESPONSE" && break

        CURRENT_TIME="$(date +%s)"
        if (( CURRENT_TIME - START_TIME >= TIMEOUT_SECONDS )); then
            echo "Timeout reached; the guest process may still be running."
            echo "Check it with: qm guest exec-status $VMID $PID"
            exit 124
        fi
        sleep "$POLL_SECONDS"
    done
fi

STDOUT="$(jq -r '."out-data" // empty' <<<"$FINAL_RESPONSE")"
STDERR="$(jq -r '."err-data" // empty' <<<"$FINAL_RESPONSE")"
EXIT_CODE="$(jq -r '.exitcode // 1' <<<"$FINAL_RESPONSE")"

[[ -z "$STDOUT" ]] || printf '%s\n' "$STDOUT"
[[ -z "$STDERR" ]] || printf '%s\n' "$STDERR" >&2
[[ "$EXIT_CODE" -eq 0 ]] || fail "PowerShell script failed with exit code $EXIT_CODE."

echo "QGA execution completed successfully."
install -d -m 700 /root/gntech-qga
install -m 700 scripts/qga-run.sh /root/gntech-qga/qga-run.sh
bash -n /root/gntech-qga/qga-run.sh

Design Guardrails

  • Sysprep on Windows 11 24H2 or later and Windows Server 2025 must not be executed under the Local System security context. Microsoft documents that this can leave the deployed OS with explorer.exe and other XAML-dependent shell components broken after sign-in.
  • QGA remains the primary non-interactive execution channel for build steps, validation, and post-clone automation, but the final Sysprep launch must come from an interactive local administrator session inside the guest.
  • The golden image must include a staged Unattend.xml so first boot after cloning does not stop in consumer-style OOBE prompts.
  • The approved Windows 11 pattern in this guide includes temporary local administrator autologon with LogonCount=1, followed by immediate removal of AutoAdminLogon through FirstLogonCommands.

Current State Verification

  1. Verify VMID 4030 is unused on H1:
qm status 4030 >/dev/null 2>&1 && {
  echo "ERROR: VMID 4030 already exists"
  exit 1
}
  1. Verify VMID 4001 is also unused before reserving it for the final template:
qm status 4001 >/dev/null 2>&1 && {
  echo "ERROR: VMID 4001 already exists"
  exit 1
}
  1. Verify the exact ISO inventory on H1 before writing any qm set commands:
pvesm list local | grep iso

Required interpretation:

  • Record the exact Windows 11 ISO path from the storage inventory.
  • Confirm virtio-win.iso exists.
  • Do not continue with guessed ISO filenames.

For this lab, prefer the approved Windows 11 Enterprise LTSC media. The build logic is compatible with Windows 11 Pro, Windows 11 Enterprise, and Windows 11 Enterprise LTSC, but the lab standard should still declare one approved edition.

Execution

Phase A - Create the Proxmox VM

On H1, after confirming the exact Windows ISO path, create the VM:

VMID=4030
WIN11_ISO='local:iso/REPLACE_WITH_EXACT_WINDOWS11_LTSC_OR_OTHER_APPROVED_ISO.iso'

qm create "$VMID" \
  --name TPL-W11E \
  --description "Windows 11 workstation golden image" \
  --ostype win11 \
  --machine q35 \
  --bios ovmf \
  --cpu host \
  --sockets 1 \
  --cores 2 \
  --memory 4096 \
  --balloon 0 \
  --scsihw virtio-scsi-single \
  --agent enabled=1,fstrim_cloned_disks=1 \
  --net0 virtio,bridge=GEILLAN,tag=110,firewall=0 \
  --vga std \
  --tablet 1 \
  --onboot 0 \
  --tags "golden-image;windows;win11;build"

qm set "$VMID" \
  --efidisk0 local-zfs:1,efitype=4m,pre-enrolled-keys=1

qm set "$VMID" \
  --tpmstate0 local-zfs:1,version=v2.0

qm set "$VMID" \
  --scsi0 local-zfs:64,discard=on,iothread=1,ssd=1

qm set "$VMID" \
  --ide2 "$WIN11_ISO",media=cdrom

qm set "$VMID" \
  --ide3 local:iso/virtio-win.iso,media=cdrom

qm set "$VMID" --boot order=ide2

qm config "$VMID"
qm start "$VMID"
qm status "$VMID"

Phase B - Manual installation until QGA is available

Use the Proxmox console for the initial Windows installation.

  1. Install the approved Windows 11 edition for this lab.
  2. If no disk is visible, load the VirtIO storage driver from the VirtIO ISO:
  3. prefer vioscsi\w11\amd64
  4. if that path does not exist, use the closest supported amd64 Windows client path present on the ISO
  5. Complete setup with a temporary local administrator account for the build.
  6. Do not join the domain.
  7. After first sign-in, set the temporary build network:
  8. IP: 172.20.110.31
  9. Mask: 255.255.255.0
  10. Gateway: 172.20.110.1
  11. DNS 1: 1.1.1.1
  12. DNS 2: 9.9.9.9

Phase C - Guest agent and base tooling

Open an elevated PowerShell session inside the Windows 11 guest and run:

$VirtioDrive = 'E:'

$QgaInstaller = Join-Path $VirtioDrive 'guest-agent\qemu-ga-x86_64.msi'
if (-not (Test-Path $QgaInstaller)) {
    throw "QEMU Guest Agent installer not found at $QgaInstaller"
}

Start-Process msiexec.exe `
    -ArgumentList "/i `"$QgaInstaller`" /qn /norestart" `
    -Wait `
    -NoNewWindow

$VirtioToolsInstaller = Join-Path $VirtioDrive 'virtio-win-gt-x64.msi'
if (-not (Test-Path $VirtioToolsInstaller)) {
    throw "VirtIO guest tools installer not found at $VirtioToolsInstaller"
}

Start-Process msiexec.exe `
    -ArgumentList "/i `"$VirtioToolsInstaller`" /qn /norestart" `
    -Wait `
    -NoNewWindow

Set-Service QEMU-GA -StartupType Automatic
Start-Service QEMU-GA

Restart-Computer

Phase D - Verify QGA from H1

After reboot:

until qm guest cmd 4030 ping >/dev/null 2>&1; do
  echo "Waiting for QEMU Guest Agent on TPL-W11E..."
  sleep 5
done

qm guest cmd 4030 get-osinfo
qm guest cmd 4030 network-get-interfaces

The interface data must include 172.20.110.31.

Phase E - Install PowerShell 7.6.3 and workstation build settings

Create /root/gntech-qga/build-tpl-w11e-stage1.ps1 on H1:

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

Write-Output '=== PowerShell 7 Download ==='
New-Item -ItemType Directory -Path 'C:\Temp' -Force | Out-Null
curl.exe -L 'https://github.com/PowerShell/PowerShell/releases/download/v7.6.3/PowerShell-7.6.3-win-x64.msi' -o 'C:\Temp\PowerShell-7.6.3-win-x64.msi'

Write-Output '=== PowerShell 7 Install ==='
$Arguments = @(
    '/i',
    'C:\Temp\PowerShell-7.6.3-win-x64.msi',
    '/qn',
    '/norestart',
    'ADD_PATH=1',
    'ENABLE_PSREMOTING=1',
    'REGISTER_MANIFEST=1',
    'USE_MU=1',
    'ENABLE_MU=1',
    'DISABLE_TELEMETRY=1'
)

$Result = Start-Process msiexec.exe -ArgumentList $Arguments -Wait -PassThru -NoNewWindow
Write-Output "PowerShell MSI exit code: $($Result.ExitCode)"

if ($Result.ExitCode -ne 0) {
    exit $Result.ExitCode
}

Write-Output '=== Validation ==='
Write-Output "Hostname: $env:COMPUTERNAME"
Write-Output "PowerShell 7: $(& 'C:\Program Files\PowerShell\7\pwsh.exe' -NoProfile -Command '$PSVersionTable.PSVersion.ToString()')"
Get-Service QEMU-GA | ForEach-Object {
    Write-Output "QEMU-GA: $($_.Status) / $($_.StartType)"
}

Execute:

/root/gntech-qga/qga-run.sh \
  4030 \
  /root/gntech-qga/build-tpl-w11e-stage1.ps1 \
  1800

Phase F - Validate the Windows 11 image before cleanup

Create /root/gntech-qga/validate-tpl-w11e.ps1 on H1:

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

$Failures = [System.Collections.Generic.List[string]]::new()
$ApprovedEdition = 'Windows 11 Enterprise LTSC'
$AllowedEditions = @(
    'Windows 11 Pro',
    'Windows 11 Enterprise',
    'Windows 11 Enterprise LTSC'
)

Write-Output '=== Operating system ==='
$OperatingSystem = Get-CimInstance Win32_OperatingSystem
Write-Output "Caption: $($OperatingSystem.Caption)"
Write-Output "Version: $($OperatingSystem.Version)"
Write-Output "Build: $($OperatingSystem.BuildNumber)"
Write-Output "Approved Edition: $ApprovedEdition"
Write-Output "Allowed Editions: $($AllowedEditions -join ', ')"

$EditionAllowed = $false
foreach ($Edition in $AllowedEditions) {
    if ($OperatingSystem.Caption -like "*$Edition*") {
        $EditionAllowed = $true
    }
}

if (-not $EditionAllowed) {
    $Failures.Add("Unexpected Windows 11 edition: $($OperatingSystem.Caption)")
}

if ($OperatingSystem.Caption -notlike "*$ApprovedEdition*") {
    Write-Output "Edition Warning: Current caption does not match the approved lab edition '$ApprovedEdition'."
}

Write-Output '=== QGA and services ==='
Get-Service QEMU-GA | ForEach-Object {
    Write-Output "QEMU-GA: $($_.Status) / $($_.StartType)"
    if ($_.Status.ToString() -ne 'Running') {
        $Failures.Add('QEMU-GA is not running.')
    }
}

Write-Output '=== PowerShell 7 ==='
$PwshPath = 'C:\Program Files\PowerShell\7\pwsh.exe'
if (-not (Test-Path $PwshPath)) {
    $Failures.Add('PowerShell 7 executable is missing.')
} else {
    $PwshVersion = & $PwshPath -NoProfile -Command '$PSVersionTable.PSVersion.ToString()'
    Write-Output "PowerShell 7 version: $PwshVersion"
    if ($PwshVersion -ne '7.6.3') {
        $Failures.Add("Unexpected PowerShell 7 version: $PwshVersion")
    }
}

Write-Output '=== TPM ==='
Get-Tpm | ForEach-Object {
    Write-Output "TPM Present: $($_.TpmPresent)"
    Write-Output "TPM Ready: $($_.TpmReady)"
    if (-not $_.TpmPresent) {
        $Failures.Add('TPM is not present.')
    }
}

Write-Output '=== Domain state ==='
$ComputerSystem = Get-CimInstance Win32_ComputerSystem
Write-Output "PartOfDomain: $($ComputerSystem.PartOfDomain)"
if ($ComputerSystem.PartOfDomain) {
    $Failures.Add('The workstation image is already domain joined.')
}

Write-Output '=== Network state ==='
Get-NetIPAddress -AddressFamily IPv4 -ErrorAction SilentlyContinue | ForEach-Object {
    Write-Output "IPv4: $($_.IPAddress) / PrefixOrigin=$($_.PrefixOrigin)"
}

if ($Failures.Count -gt 0) {
    Write-Output '=== Failures ==='
    $Failures | ForEach-Object { Write-Output $_ }
    exit 1
}

Write-Output 'Validation completed successfully.'

Execute:

/root/gntech-qga/qga-run.sh \
  4030 \
  /root/gntech-qga/validate-tpl-w11e.ps1 \
  1800

Phase G - Cleanup, DHCP reset, and Sysprep

Create /root/gntech-qga/finalize-tpl-w11e.ps1 on H1:

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

Write-Output 'Running component cleanup.'
$Dism = Start-Process `
    -FilePath 'dism.exe' `
    -ArgumentList '/Online', '/Cleanup-Image', '/StartComponentCleanup' `
    -Wait `
    -PassThru `
    -NoNewWindow

if ($Dism.ExitCode -notin 0, 3010) {
    throw "DISM cleanup failed with exit code $($Dism.ExitCode)."
}

Write-Output 'Cleaning temporary files.'
$TemporaryPaths = @(
    'C:\Windows\Temp\*',
    "$env:TEMP\*",
    'C:\Windows\SoftwareDistribution\Download\*'
)

foreach ($Path in $TemporaryPaths) {
    Remove-Item -Path $Path -Recurse -Force -ErrorAction SilentlyContinue
}

Write-Output 'Resetting network adapter to DHCP.'
$Adapter = Get-NetAdapter |
    Where-Object Status -eq 'Up' |
    Sort-Object ifIndex |
    Select-Object -First 1

if (-not $Adapter) {
    throw 'No active network adapter was found.'
}

Set-NetIPInterface `
    -InterfaceIndex $Adapter.ifIndex `
    -AddressFamily IPv4 `
    -Dhcp Enabled

Get-NetIPAddress `
    -InterfaceIndex $Adapter.ifIndex `
    -AddressFamily IPv4 `
    -ErrorAction SilentlyContinue |
    Where-Object PrefixOrigin -eq 'Manual' |
    Remove-NetIPAddress -Confirm:$false -ErrorAction SilentlyContinue

Set-DnsClientServerAddress `
    -InterfaceIndex $Adapter.ifIndex `
    -ResetServerAddresses

Write-Output 'Removing build-network default routes from persistent and active stores.'
foreach ($policyStore in 'PersistentStore','ActiveStore') {
    Get-NetRoute `
        -PolicyStore $policyStore `
        -InterfaceIndex $Adapter.ifIndex `
        -AddressFamily IPv4 `
        -DestinationPrefix '0.0.0.0/0' `
        -ErrorAction SilentlyContinue |
        Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue
}

Write-Output 'Stopping services that should not remain active during capture.'
Stop-Service wuauserv -Force -ErrorAction SilentlyContinue

Write-Output 'Finalization completed successfully.'

Execute:

/root/gntech-qga/qga-run.sh \
  4030 \
  /root/gntech-qga/finalize-tpl-w11e.ps1 \
  1800

Create C:\Windows\Panther\Unattend\Unattend.xml before capture.

Create /root/gntech-qga/stage-tpl-w11e-unattend.ps1 on H1:

$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

$UnattendDir = 'C:\Windows\Panther\Unattend'
$UnattendPath = Join-Path $UnattendDir 'Unattend.xml'

New-Item -ItemType Directory -Path $UnattendDir -Force | Out-Null

$UnattendXml = @'
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend">
  <settings pass="oobeSystem">
    <component name="Microsoft-Windows-International-Core" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="" versionScope="nonSxS">
      <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="" versionScope="nonSxS">
      <TimeZone>SA Western Standard Time</TimeZone>
      <RegisteredOrganization>GNTECH</RegisteredOrganization>
      <RegisteredOwner>GNTECH</RegisteredOwner>
      <OOBE>
        <HideEULAPage>true</HideEULAPage>
        <HideLocalAccountScreen>true</HideLocalAccountScreen>
        <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
        <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
        <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
        <ProtectYourPC>3</ProtectYourPC>
      </OOBE>
    </component>
  </settings>
</unattend>
'@

Set-Content -Path $UnattendPath -Value $UnattendXml -Encoding UTF8

Write-Output '=== Unattend Staged ==='
Write-Output "Path: $UnattendPath"
Get-Content $UnattendPath | ForEach-Object { $_ }

Execute:

/root/gntech-qga/qga-run.sh \
  4030 \
  /root/gntech-qga/stage-tpl-w11e-unattend.ps1 \
  1800

Important interpretation:

  • This answer file is intentionally edition-agnostic across the supported lab set: Pro, Enterprise, and Enterprise LTSC.
  • The file is there to suppress first-boot OOBE friction. It is not there to join the domain, inject static networking, or create a permanent deployment operator account.
  • In the validated lab, the answer file also required temporary local administrator autologon so the first cloned boot completed without interactive prompts.

Launch Sysprep correctly

Do not run Sysprep from QGA.

Instead:

  1. Open the Proxmox console for 4030.
  2. Sign in with the temporary local build administrator.
  3. Open an elevated PowerShell or cmd.exe.
  4. Launch:
Start-Process `
  'C:\Windows\System32\Sysprep\Sysprep.exe' `
  -ArgumentList '/generalize','/oobe','/shutdown','/mode:vm' `
  -Wait

Or from cmd.exe:

C:\Windows\System32\Sysprep\Sysprep.exe /generalize /oobe /shutdown /mode:vm

Only after the VM powers off should you continue on H1.

Wait for shutdown:

until [ "$(qm status 4030 | awk '{print $2}')" = "stopped" ]; do
  echo "Waiting for Sysprep shutdown..."
  sleep 5
done

Phase H - Clone-test the generalized image, then convert to the final template VMID

If you created any pre-Sysprep or rollback snapshots on the build VM, they are valid only for the build stage. Proxmox template conversion must happen on a VM with no snapshots in its chain.

Verify snapshot state on the build VM:

qm listsnapshot 4030

Expected interpretation:

  • If 4030 still has snapshots, do not convert it directly to a template.
  • Keep 4030 as the transient build VM and clone the generalized image into 4001.
  • Before converting 4001, verify 4001 has no snapshots. If any snapshot exists on 4001, delete it first.

New standard:

  • do not convert a generalized source VM directly into a template without a clone test
  • first create a temporary validation clone from the generalized source
  • validate first boot and guest readiness
  • then create the final template VMID

Example validation pattern:

qm clone 4030 4041 \
  --name TEST-W11E \
  --full 1 \
  --storage local-zfs

qm set 4041 --net0 virtio,bridge=GEILLAN,tag=30,firewall=0

qm start 4041

Only after the validation clone passes should you create the final template VMID.

Validation requirements for 4041:

  • OOBEInProgress: 0
  • SystemSetupInProgress: 0
  • ImageState: IMAGE_STATE_COMPLETE
  • no product key or residual first-boot prompt appears
  • the clone receives a valid DHCP lease on the validation network
  • the only default gateway is the gateway delivered by DHCP; no build-network route remains in PersistentStore
  • PowerShell 7.6.3 remains installed
  • QEMU-GA remains running

Clone the stopped, generalized build VM into the final template VMID:

qm clone 4030 4001 \
  --name TPL-W11E \
  --full 1

qm config 4001
qm status 4001
qm listsnapshot 4001

In the validated lab, the generalized source was first rebuilt and tested on 4014, then the approved final template slot 4001 was replaced only after the clone test passed.

Detach installation media on the final VMID, set metadata, then convert:

qm set 4001 --delete ide2
qm set 4001 --delete ide3

qm set 4001 \
  --boot order=scsi0 \
  --description "Windows 11 Enterprise LTSC golden image. Validated, generalized with Sysprep, QEMU Guest Agent enabled. Do not start directly."

qm listsnapshot 4001
qm template 4001

qm config 4001
qm status 4001

Validation

  • qm config 4001 shows:
  • template: 1
  • agent: enabled=1
  • bios: ovmf
  • machine: pc-q35
  • tpmstate0 present
  • boot: order=scsi0
  • name: TPL-W11E
  • a post-capture clone boot test passed before conversion
  • qm status 4001 shows stopped.
  • qm listsnapshot 4001 shows no snapshots before qm template 4001.
  • QEMU Guest Agent responds through qm guest cmd 4030 ping before Sysprep.
  • PowerShell 7.6.3 is installed.
  • TPM is present in the guest.
  • C:\Windows\Panther\Unattend\Unattend.xml exists before Sysprep.
  • Sysprep was launched from an interactive local administrator session, not through QGA.
  • the post-capture clone does not prompt for any residual first-boot wizard step
  • Get-NetRoute -PolicyStore PersistentStore -DestinationPrefix '0.0.0.0/0' does not expose the build gateway

Operational Notes

  • The July 18, 2026 audit found that current template 4001 still retains persistent build gateway 172.20.110.1. The corrected cleanup above must be applied during an interactive Sysprep recapture before the template is considered clean again.
  • P03-T06 can remove that exact legacy route through an explicit, validated onboarding switch, but that is a controlled lab remediation rather than a replacement for recapturing the source image.

  • If a deployed clone still shows the Windows 11 black-shell symptom after first logon, treat that as an exception path and investigate whether Sysprep was accidentally executed as System.

  • For temporary recovery of a broken shell on an already deployed machine, the following commands were validated in this lab:

Stop-Process -Name explorer -Force -ErrorAction SilentlyContinue
Stop-Process -Name sihost -Force -ErrorAction SilentlyContinue
Start-Sleep -Seconds 5
Start-Process 'C:\Windows\explorer.exe'
- The image is not domain joined. - The installed edition is one of the supported editions, and for this lab should match Windows 11 Enterprise LTSC. - ISOs are detached after finalization. - The template is not started again after Sysprep.

Evidence

  • Output of qm config 4030
  • Output of qm status 4030
  • Output of qm listsnapshot 4030
  • Output of qm config 4001
  • Output of qm status 4001
  • Output of validate-tpl-w11e.ps1
  • Output of validation from the post-capture clone
  • Output showing PowerShell MSI exit code: 0
  • Evidence that the workstation image was not domain joined before capture

Rollback

  • Destroy and recreate VMID 4030 if firmware, TPM, disks, or ISO bindings are wrong.
  • Restore from a pre-Sysprep snapshot if cleanup must be redone.
  • Delete any snapshot on the final template candidate before running qm template.
  • Never convert a Windows 11 image to template if QGA, TPM, or non-domain-joined validation failed.