Skip to content

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 QGA is available
  • use QGA as the primary remote execution path afterward
  • stage Unattend.xml before capture
  • launch Sysprep only 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 Core has a smaller footprint and reduced attack surface
  • SConfig is the primary local configuration tool on Server Core
  • for more than a handful of servers, automated installation approaches such as unattend.xml are preferred over manual local configuration
  • you cannot convert between Server Core and Desktop Experience after 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 Core by default for infrastructure roles that are fully supportable without local GUI tools.
  • Use Desktop Experience only where the workload, vendor support, or operational model genuinely requires it.
  • Do not install App Compatibility Feature on Demand in the template by default unless the customer baseline requires it.
  • In this validated lab, App Compatibility FoD was 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-T03 so vlan110-build exists and is reachable.
  • Confirm VMID 4002 is unused before creation.
  • Confirm the exact Windows Server ISO path on H1.
  • Confirm virtio-win.iso is present.
  • Confirm /root/gntech-qga/qga-run.sh already works from the earlier phases.
  • Confirm you are intentionally choosing the Server Core edition. 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:

  1. Choose Windows Server 2025 Standard or Windows Server 2025 Datacenter without Desktop Experience.
  2. If no disk appears, choose Load driver.
  3. From the VirtIO ISO, try vioscsi\2k25\amd64.
  4. If that path does not exist, use the closest supported amd64 path present on the ISO.
  5. Complete installation and set the local Administrator password.

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:

  • SConfig for quick local setup
  • or option 15 to exit to PowerShell, 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:

Get-Volume | Sort-Object DriveLetter | Format-Table DriveLetter,FileSystemLabel,SizeRemaining,Size

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:

/root/gntech-qga/qga-run.sh \
  4002 \
  /root/gntech-qga/build-ws2025-core-stage1.ps1 \
  1800

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:

/root/gntech-qga/qga-run.sh \
  4002 \
  /root/gntech-qga/validate-ws2025-core.ps1 \
  1800

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 sshd and wuauserv before capture
  • stage C:\Windows\Panther\Unattend\Unattend.xml
  • do not execute Sysprep from QGA
  • launch Sysprep from 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 Administrator autologon for the first cloned boot so post-OOBE friction is removed

Validated behavior from this lab:

  • an Unattend.xml without temporary autologon still allowed OOBE completion
  • but the first cloned boot still prompted for password-change or interactive logon steps
  • adding temporary Administrator autologon produced the expected low-touch first boot

For that reason, the approved Server Core pattern in this guide is:

  • temporary Administrator autologon in Unattend.xml
  • LogonCount=1
  • immediate removal of AutoAdminLogon through FirstLogonCommands

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: 0
  • SystemSetupInProgress: 0
  • ImageState: IMAGE_STATE_COMPLETE
  • first boot completes without password-change friction
  • the clone receives a valid DHCP lease on the validation network
  • QGA is running
  • PowerShell 7.6.3 is present
  • ServerCore.AppCompatibility~~~~0.0.1.0 remains Installed

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:

Add-WindowsCapability -Online -Name "ServerCore.AppCompatibility~~~~0.0.1.0"

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 4002 shows:
  • template: 1
  • agent: enabled=1
  • boot: order=scsi0
  • name: TPL-WS2025-CORE
  • a post-capture clone boot test passed before conversion
  • qm listsnapshot 4002 shows no snapshots before qm template 4002
  • The guest was built from a Server Core edition, not Desktop Experience.
  • PowerShell 7.6.3 is installed.
  • QEMU Guest Agent responds before capture.
  • The template is not domain joined.
  • No forbidden server roles are installed.
  • Unattend.xml exists before Sysprep.
  • Sysprep was launched from an interactive local administrator session, not through QGA.

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 4002 if the wrong edition was selected.
  • Do not attempt to convert between Server Core and Desktop Experience; rebuild instead.
  • Restore from a pre-Sysprep snapshot if cleanup or answer file staging must be redone.