P02-T06 - Windows Server Core Template
Objective
Create a reusable Windows Server 2025 Server Core template for roles that do not require Desktop Experience, such as:
- management servers
- jump hosts
- backup components
- infrastructure utilities
- application servers that are fully supportable on Server Core
This runbook is intentionally aligned with the existing template pattern in this guide:
- build on
VLAN 110 - use the Proxmox console only until
QGAis available - use
QGAas the primary remote execution path afterward - stage
Unattend.xmlbefore capture - launch
Syspreponly from an interactive local administrator session inside the guest
Why Server Core
Microsoft recommends choosing Server Core unless you have a specific need for the extra GUI components included in Desktop Experience. Microsoft also states that:
Server Corehas a smaller footprint and reduced attack surfaceSConfigis the primary local configuration tool on Server Core- for more than a handful of servers, automated installation approaches such as
unattend.xmlare preferred over manual local configuration - you cannot convert between
Server CoreandDesktop Experienceafter installation; changing later requires a clean install
Sources:
- Microsoft Learn: Server Core vs Server with Desktop Experience
- https://learn.microsoft.com/en-us/windows-server/get-started/install-options-server-core-desktop-experience
- Microsoft Learn: What is Server Core?
- https://learn.microsoft.com/en-us/windows-server/administration/server-core/what-is-server-core
- Microsoft Learn: Configure Server Core with SConfig
- https://learn.microsoft.com/en-us/windows-server/administration/server-core/server-core-sconfig
Inputs
| Key | Value |
|---|---|
| Suggested VMID | 4002 |
| Template name | TPL-WS2025-CORE |
| Suggested build IP | 172.20.110.12/24 |
| Build gateway | 172.20.110.1 |
| Build DNS | 1.1.1.1, 9.9.9.9 |
| Build VLAN | 110 |
| Bridge | GEILLAN |
| Storage | local-zfs |
| 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 |
|---|---|
| OS edition | Windows Server 2025 Standard or Datacenter without Desktop Experience |
| Interface model | Server Core |
| Firmware | OVMF |
| Machine type | q35 |
| 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 |
| Unattend staged before capture | Yes |
| App Compatibility FoD installed by default | Customer-driven; enabled in this validated lab build |
Decision rules
- Use
Server Coreby default for infrastructure roles that are fully supportable without local GUI tools. - Use
Desktop Experienceonly where the workload, vendor support, or operational model genuinely requires it. - Do not install
App Compatibility Feature on Demandin the template by default unless the customer baseline requires it. - In this validated lab,
App Compatibility FoDwas intentionally installed and tested as part of the template baseline. - Do not use this template for a domain controller unless you explicitly validate a Server Core DC standard for that customer.
Prechecks
- Complete
P01-T03sovlan110-buildexists and is reachable. - Confirm
VMID 4002is unused before creation. - Confirm the exact Windows Server ISO path on
H1. - Confirm
virtio-win.isois present. - Confirm
/root/gntech-qga/qga-run.shalready works from the earlier phases. - Confirm you are intentionally choosing the
Server Coreedition. You cannot switch it to Desktop Experience later without reinstalling.
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
Execution
Phase A - Create the build VM
On H1:
VMID=4002
WIN_ISO='local:iso/REPLACE_WITH_EXACT_WINDOWS_SERVER_2025_ISO.iso'
qm status "$VMID" >/dev/null 2>&1 && {
echo "ERROR: VMID $VMID already exists"
exit 1
}
qm create "$VMID" \
--name TPL-WS2025-CORE \
--description "Windows Server 2025 Server Core 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;server2025;core;build"
qm set "$VMID" \
--efidisk0 local-zfs:1,efitype=4m,pre-enrolled-keys=0
qm set "$VMID" \
--scsi0 local-zfs:64,discard=on,iothread=1,ssd=1
qm set "$VMID" \
--ide2 "$WIN_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 - Install Windows Server Core manually
Use the Proxmox console until the guest is installed and the local administrator password is set.
At setup:
- Choose
Windows Server 2025 StandardorWindows Server 2025 DatacenterwithoutDesktop Experience. - If no disk appears, choose
Load driver. - From the VirtIO ISO, try
vioscsi\2k25\amd64. - If that path does not exist, use the closest supported
amd64path present on the ISO. - Complete installation and set the local
Administratorpassword.
Important:
- Do not select any edition with
Desktop Experience. - Do not join the domain.
- Do not install server roles yet.
Phase C - Prepare the build network from the Server Core console
On first sign-in, SConfig should start automatically on modern Server Core builds. Microsoft documents SConfig as the primary local configuration tool for Server Core, but also notes that for scale you should prefer automated methods such as answer files.
Use either:
SConfigfor quick local setup- or option
15to exit toPowerShell, then run commands directly
Configure the temporary build network:
- IP:
172.20.110.12 - Prefix:
24 - Gateway:
172.20.110.1 - DNS 1:
1.1.1.1 - DNS 2:
9.9.9.9
If you prefer PowerShell directly, run:
$Adapter = Get-NetAdapter |
Where-Object Status -eq 'Up' |
Sort-Object ifIndex |
Select-Object -First 1
Rename-Computer -NewName 'TPL-WS2025-CORE-BUILD' -Force
Set-TimeZone -Id 'SA Western Standard Time'
New-NetIPAddress `
-InterfaceIndex $Adapter.ifIndex `
-IPAddress '172.20.110.12' `
-PrefixLength 24 `
-DefaultGateway '172.20.110.1'
Set-DnsClientServerAddress `
-InterfaceIndex $Adapter.ifIndex `
-ServerAddresses @('1.1.1.1','9.9.9.9')
Phase D - Install QEMU Guest Agent and VirtIO guest tools
From the Server Core console, mount points may differ. Confirm the VirtIO CD drive first:
Then install the tools. Replace E: if the VirtIO media is mounted differently:
$VirtioDrive = 'E:'
$QgaInstaller = Join-Path $VirtioDrive 'guest-agent\qemu-ga-x86_64.msi'
$VirtioToolsInstaller = Join-Path $VirtioDrive 'virtio-win-gt-x64.msi'
if (-not (Test-Path $QgaInstaller)) {
throw "QEMU Guest Agent installer not found at $QgaInstaller"
}
if (-not (Test-Path $VirtioToolsInstaller)) {
throw "VirtIO guest tools installer not found at $VirtioToolsInstaller"
}
Start-Process msiexec.exe `
-ArgumentList "/i `"$QgaInstaller`" /qn /norestart" `
-Wait `
-NoNewWindow
Start-Process msiexec.exe `
-ArgumentList "/i `"$VirtioToolsInstaller`" /qn /norestart" `
-Wait `
-NoNewWindow
Set-Service QEMU-GA -StartupType Automatic
Start-Service QEMU-GA
Restart-Computer
Phase E - Verify QGA from H1
On H1:
until qm guest cmd 4002 ping >/dev/null 2>&1; do
echo "Waiting for QEMU Guest Agent on TPL-WS2025-CORE..."
sleep 5
done
qm guest cmd 4002 get-osinfo
qm guest cmd 4002 network-get-interfaces
The interface output must include 172.20.110.12.
Phase F - Install PowerShell 7.6.3 and apply base settings
Create /root/gntech-qga/build-ws2025-core-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:
Phase G - Validate the Server Core image
Create /root/gntech-qga/validate-ws2025-core.ps1 on H1:
$ErrorActionPreference = 'Stop'
$ProgressPreference = 'SilentlyContinue'
$Failures = [System.Collections.Generic.List[string]]::new()
Write-Output '=== Operating system ==='
$OperatingSystem = Get-CimInstance Win32_OperatingSystem
Write-Output "Caption: $($OperatingSystem.Caption)"
Write-Output "Version: $($OperatingSystem.Version)"
Write-Output "Build: $($OperatingSystem.BuildNumber)"
if ($OperatingSystem.Caption -notlike '*Windows Server 2025*') {
$Failures.Add("Unexpected operating system: $($OperatingSystem.Caption)")
}
if ($OperatingSystem.Caption -like '*Desktop Experience*') {
$Failures.Add('Desktop Experience edition detected. Server Core was expected.')
}
Write-Output '=== QGA ==='
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 '=== Domain state ==='
$ComputerSystem = Get-CimInstance Win32_ComputerSystem
Write-Output "PartOfDomain: $($ComputerSystem.PartOfDomain)"
if ($ComputerSystem.PartOfDomain) {
$Failures.Add('The Server Core image is already domain joined.')
}
Write-Output '=== Installed roles ==='
Get-WindowsFeature | Where-Object Installed | ForEach-Object {
Write-Output "InstalledFeature: $($_.Name)"
}
foreach ($ForbiddenRole in 'AD-Domain-Services','DNS','DHCP','Web-Server','FS-FileServer','Hyper-V') {
$Feature = Get-WindowsFeature -Name $ForbiddenRole -ErrorAction SilentlyContinue
if ($Feature -and $Feature.InstallState.ToString() -eq 'Installed') {
$Failures.Add("Forbidden role installed: $ForbiddenRole")
}
}
if ($Failures.Count -gt 0) {
Write-Output '=== Failures ==='
$Failures | ForEach-Object { Write-Output $_ }
exit 1
}
Write-Output 'Validation completed successfully.'
Execute:
Phase H - Finalize, stage Unattend, and capture
Use the same finalization pattern as P02-T05, but apply it to VMID 4002.
Key rules:
- reset the build NIC to
DHCP - stop
sshdandwuauservbefore capture - stage
C:\Windows\Panther\Unattend\Unattend.xml - do not execute
SysprepfromQGA - launch
Sysprepfrom an interactive local administrator session in the Proxmox console - validate a clone before converting the source VM into a template
- delete snapshots from the source VM before
qm template
Recommended Unattend.xml staging pattern:
- use the same approach as the desktop server and Windows 11 templates
- keep the answer file limited to predictable first-boot behavior
- do not hardcode domain join, static networking, or customer credentials
- for this validated Server Core flow, include temporary
Administratorautologon for the first cloned boot so post-OOBE friction is removed
Validated behavior from this lab:
- an
Unattend.xmlwithout temporary autologon still allowedOOBEcompletion - but the first cloned boot still prompted for password-change or interactive logon steps
- adding temporary
Administratorautologon produced the expected low-touch first boot
For that reason, the approved Server Core pattern in this guide is:
- temporary
Administratorautologon inUnattend.xml LogonCount=1- immediate removal of
AutoAdminLogonthroughFirstLogonCommands
Run Sysprep interactively:
Start-Process `
'C:\Windows\System32\Sysprep\Sysprep.exe' `
-ArgumentList '/generalize','/oobe','/shutdown','/mode:vm' `
-Wait
After shutdown on H1, do not convert immediately. First validate a clone:
qm clone 4002 4042 \
--name TEST-WS2025-CORE \
--full 1 \
--storage local-zfs
qm set 4042 --net0 virtio,bridge=GEILLAN,tag=30,firewall=0
qm start 4042
Validation rules for the clone:
OOBEInProgress: 0SystemSetupInProgress: 0ImageState: IMAGE_STATE_COMPLETE- first boot completes without password-change friction
- the clone receives a valid
DHCPlease on the validation network QGAis runningPowerShell 7.6.3is presentServerCore.AppCompatibility~~~~0.0.1.0remainsInstalled
Only after that clone test passes:
qm listsnapshot 4002
qm delsnapshot 4002 rc1-pre-sysprep
qm listsnapshot 4002
qm set 4002 --delete ide2
qm set 4002 --delete ide3
qm set 4002 \
--boot order=scsi0 \
--description "Windows Server 2025 Server Core golden image. Validated, generalized with Sysprep, QEMU Guest Agent enabled. Do not start directly."
qm template 4002
qm config 4002
qm status 4002
Optional: App Compatibility Feature on Demand
Do not install this by default.
If a specific customer requires it, Microsoft documents the capability name as:
Source:
- Microsoft Learn: Install Server Core Application Compatibility Feature on Demand
- https://learn.microsoft.com/en-us/windows-server/get-started/server-core-app-compatibility-feature-on-demand
Use it only when the role genuinely needs the extra compatibility layer.
Validation
qm config 4002shows:template: 1agent: enabled=1boot: order=scsi0name: TPL-WS2025-CORE- a post-capture clone boot test passed before conversion
qm listsnapshot 4002shows no snapshots beforeqm template 4002- The guest was built from a
Server Coreedition, notDesktop Experience. PowerShell 7.6.3is installed.QEMU Guest Agentresponds before capture.- The template is not domain joined.
- No forbidden server roles are installed.
Unattend.xmlexists beforeSysprep.Sysprepwas launched from an interactive local administrator session, not throughQGA.
Evidence
- Output of
qm config 4002 - Output of
qm status 4002 - Output of
validate-ws2025-core.ps1 - Output of validation from the post-capture clone
- Output of
qm listsnapshot 4002 - Screenshot or transcript showing the install option selected without
Desktop Experience
Rollback
- Destroy and rebuild
VMID 4002if the wrong edition was selected. - Do not attempt to convert between
Server CoreandDesktop Experience; rebuild instead. - Restore from a pre-Sysprep snapshot if cleanup or answer file staging must be redone.