Skip to content

P02-T05 - Finalize and Convert Template

Objective

Finalize the validated golden image, reset networking to DHCP, stop capture-unsafe services, stage Unattend.xml, run Sysprep correctly, validate a post-capture clone, and only then convert VMID 4000 into the approved Proxmox template.

Inputs

Key Value
VMID 4000
Snapshot rc1-pre-sysprep
Final name TPL-WS2025

Prechecks

  • Complete P02-T04.
  • Do not continue unless the golden image validation passed cleanly.
  • Do not plan to launch Sysprep through QGA, qm guest exec, or any workflow that runs it as Local System.

Design guardrails

  • On modern Windows 11 and Windows Server 2025 builds, running Sysprep as Local System can leave the deployed OS in a broken post-logon shell state.
  • QGA remains the primary automation path for cleanup, validation, and capture preparation.
  • The final Sysprep launch must come from an interactive local administrator session inside the guest.
  • C:\Windows\Panther\Unattend\Unattend.xml must exist before capture so first boot from the template is predictable and low-touch.
  • A generalized VM is not approved for template conversion until a clone boot test succeeds.
  • Any snapshot chain must be removed before qm template.

Execution

The following repository helper is the transport used by the QGA execution steps below. Copy it to H1 once, then make it executable:

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
  1. Create the final pre-Sysprep snapshot:
qm snapshot 4000 rc1-pre-sysprep \
  --description "Windows Server 2025 golden image validated before cleanup and Sysprep"
  1. Create /root/gntech-qga/finalize-golden-image.ps1:
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'

Write-Output 'Starting golden image finalization.'

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

foreach ($PolicyStore in 'ActiveStore', 'PersistentStore') {
    Get-NetRoute `
        -PolicyStore $PolicyStore `
        -InterfaceIndex $Adapter.ifIndex `
        -AddressFamily IPv4 `
        -DestinationPrefix '0.0.0.0/0' `
        -ErrorAction SilentlyContinue |
        Remove-NetRoute -Confirm:$false -ErrorAction SilentlyContinue
}

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

if (Get-NetRoute -PolicyStore PersistentStore -AddressFamily IPv4 -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue) {
    throw 'A persistent default route remains after network cleanup.'
}

Write-Output 'Stopping services that should not remain active during capture.'

Stop-Service sshd -Force -ErrorAction SilentlyContinue
Set-Service sshd -StartupType Disabled

Stop-Service wuauserv -Force -ErrorAction SilentlyContinue

Write-Output 'Finalization completed successfully.'
  1. Execute finalization:
/root/gntech-qga/qga-run.sh \
  4000 \
  /root/gntech-qga/finalize-golden-image.ps1 \
  1800
  1. Validate DHCP reset and service state:
qm guest exec 4000 -- \
  powershell.exe \
  -NoProfile \
  -Command "\
Get-NetIPInterface -AddressFamily IPv4 | \
Where-Object InterfaceAlias -eq 'Ethernet' | \
Select-Object InterfaceAlias,Dhcp; \
Get-DnsClientServerAddress -InterfaceAlias 'Ethernet' -AddressFamily IPv4; \
Get-Service QEMU-GA,sshd | Select-Object Name,Status,StartType"

The 172.20.110.10 address must disappear.

  1. Stage Unattend.xml before capture.

Create /root/gntech-qga/stage-ws2025-unattend.ps1:

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

$PlainPassword = 'REPLACE_WITH_CURRENT_LOCAL_ADMINISTRATOR_PASSWORD'
$XmlPassword = [System.Security.SecurityElement]::Escape($PlainPassword)

$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="specialize">
    <component name="Microsoft-Windows-Shell-Setup" processorArchitecture="amd64" publicKeyToken="31bf3856ad364e35" language="" versionScope="nonSxS">
      <ProductKey>TVRH6-WHNXV-R9WG3-9XRFY-MY832</ProductKey>
    </component>
  </settings>
  <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>
        <HideOEMRegistrationScreen>true</HideOEMRegistrationScreen>
        <HideOnlineAccountScreens>true</HideOnlineAccountScreens>
        <HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
        <ProtectYourPC>3</ProtectYourPC>
      </OOBE>
      <UserAccounts>
        <AdministratorPassword>
          <Value>$XmlPassword</Value>
          <PlainText>true</PlainText>
        </AdministratorPassword>
      </UserAccounts>
      <AutoLogon>
        <Password>
          <Value>$XmlPassword</Value>
          <PlainText>true</PlainText>
        </Password>
        <Enabled>true</Enabled>
        <Username>Administrator</Username>
        <LogonCount>1</LogonCount>
      </AutoLogon>
      <FirstLogonCommands>
        <SynchronousCommand wcm:action="add" xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State">
          <Order>1</Order>
          <Description>Disable AutoAdminLogon after bootstrap</Description>
          <CommandLine>cmd /c reg add "HKLM\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon" /v AutoAdminLogon /t REG_SZ /d 0 /f</CommandLine>
        </SynchronousCommand>
      </FirstLogonCommands>
    </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 \
  4000 \
  /root/gntech-qga/stage-ws2025-unattend.ps1 \
  1800
  1. Launch Sysprep correctly.

Do not run Sysprep from QGA.

Instead:

  1. Open the Proxmox console for 4000.
  2. Sign in with the 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.

  1. Wait for shutdown:
until [ "$(qm status 4000 | awk '{print $2}')" = "stopped" ]; do
  echo "Waiting for Sysprep shutdown..."
  sleep 5
done
  1. Validate a clone before conversion.

Recommended pattern:

  • keep 4000 stopped after Sysprep
  • clone it to a temporary validation VMID
  • boot the clone on a network with working DHCP
  • verify first boot, QGA, non-domain-joined state, and dynamic addressing

Example:

qm clone 4000 4040 \
  --name TEST-WS2025 \
  --full 1 \
  --storage local-zfs

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

qm start 4040

Expected interpretation:

  • the clone completes first boot successfully
  • the clone does not require unexpected interactive recovery steps
  • the clone receives a valid DHCP lease on the selected validation network
  • the clone does not prompt for a product key or manual skip in OOBE

Do not convert 4000 to template until the clone test passes.

  1. Remove snapshots from the source VM before conversion.

Check snapshot state:

qm listsnapshot 4000

If snapshots exist, delete them before template conversion. Example:

qm delsnapshot 4000 rc1-pre-sysprep
qm listsnapshot 4000

qm template must not be executed while the source VM still has snapshots in its chain.

  1. Detach installation media and convert:
qm set 4000 --delete ide2
qm set 4000 --delete ide3

qm set 4000 \
  --boot order=scsi0 \
  --description "Windows Server 2025 Standard golden image. Validated, generalized with Sysprep, QEMU Guest Agent enabled. Do not start directly."

qm template 4000

qm config 4000
qm status 4000

Validation

  • qm config 4000 shows:
  • template: 1
  • agent: enabled=1
  • boot: order=scsi0
  • name: TPL-WS2025
  • a post-capture clone boot test passed before conversion
  • qm listsnapshot 4000 shows no snapshots before qm template 4000
  • 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 a product key
  • ISOs are detached.
  • The template is not started again after Sysprep.

Evidence

  • Output of finalization script
  • Output of stage-ws2025-unattend.ps1
  • Output of validation from the post-capture clone
  • Output of qm listsnapshot 4000
  • Output of qm config 4000
  • Output of qm status 4000

Rollback

  • Restore the rc1-pre-sysprep snapshot if post-validation cleanup must be redone.
  • Never keep using a template that failed Sysprep or retained incorrect network state.
  • If first interactive logon after deployment shows shell or post-logon issues, rebuild from a pre-Sysprep snapshot and verify that Sysprep was not executed as System.