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
SysprepthroughQGA,qm guest exec, or any workflow that runs it asLocal System.
Design guardrails
- On modern Windows 11 and Windows Server 2025 builds, running
SysprepasLocal Systemcan leave the deployed OS in a broken post-logon shell state. QGAremains the primary automation path for cleanup, validation, and capture preparation.- The final
Syspreplaunch must come from an interactive local administrator session inside the guest. C:\Windows\Panther\Unattend\Unattend.xmlmust 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
- Create the final pre-Sysprep snapshot:
qm snapshot 4000 rc1-pre-sysprep \
--description "Windows Server 2025 golden image validated before cleanup and Sysprep"
- 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.'
- Execute finalization:
- 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.
- Stage
Unattend.xmlbefore 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:
- Launch
Sysprepcorrectly.
Do not run Sysprep from QGA.
Instead:
- Open the Proxmox console for
4000. - Sign in with the local build administrator.
- Open an elevated
PowerShellorcmd.exe. - Launch:
Start-Process `
'C:\Windows\System32\Sysprep\Sysprep.exe' `
-ArgumentList '/generalize','/oobe','/shutdown','/mode:vm' `
-Wait
Or from cmd.exe:
Only after the VM powers off should you continue on H1.
- Wait for shutdown:
until [ "$(qm status 4000 | awk '{print $2}')" = "stopped" ]; do
echo "Waiting for Sysprep shutdown..."
sleep 5
done
- Validate a clone before conversion.
Recommended pattern:
- keep
4000stopped afterSysprep - 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
DHCPlease 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.
- Remove snapshots from the source VM before conversion.
Check snapshot state:
If snapshots exist, delete them before template conversion. Example:
qm template must not be executed while the source VM still has snapshots in its chain.
- 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 4000shows:template: 1agent: enabled=1boot: order=scsi0name: TPL-WS2025- a post-capture clone boot test passed before conversion
qm listsnapshot 4000shows no snapshots beforeqm template 4000C:\Windows\Panther\Unattend\Unattend.xmlexists beforeSysprep.Sysprepwas launched from an interactive local administrator session, not throughQGA.- 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-sysprepsnapshot 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
Sysprepwas not executed asSystem.