π§ͺ Offline Boot Testing & Generalization of VHDX OS Images
VHDX Imaging and Azure Upload Process Guide
This guide follows the Disk2vhd imaging and Azure upload process. In networks where the .vhdx
is synced to local hypervisor storage, the image must be tested offline to validate bootability before being generalized and cleaned.
1. π Boot Test in Local Staging Hypervisor
Objective:
Ensure the .vhdx
image boots in a virtual machine within a completely offline staging environment.
Steps:
- Import the
.vhdx
into your hypervisor (e.g., Hyper-V, Proxmox, VMware Workstation). - Create a test VM:
- Connect the disk as the primary boot volume.
- Ensure no internet/network adapters are connected.
- Power on the VM.
π’ Special Case: Ridgebrook Client Deployment
For Ridgebrook:
- Images stored in the Azure file share:
- Storage account:
clientosimages01e2usdtc
- File share:
client-os-images-01
- Storage account:
- The image should be synced to the local staging hypervisor:
- Hypervisor name:
ELDERBRAIN
- Local path:
D:\Virtual Hard Disks\client-os-images-01
- Hypervisor name:
- Ridgebrook
.vhdx
images should be placed into a subfolder matching their PSA company name (e.g.,ridgebrook-industrial
).
Once synced, follow the standard boot validation and conversion process below.
2. π Convert MBR to GPT (If Needed)
If the VM fails to boot, the .vhdx
likely uses MBR instead of GPT.
β Option 1: MBR2GPT (Recommended)
Steps:
mbr2gpt /validate /disk:0 /allowFullOS
mbr2gpt /convert /disk:0 /allowFullOS
Make sure the OS volume is the last volume and delete any non-essential partitions (see below).
β οΈ Option 2: gptgen (Advanced / Manual Bootloader Required)
gptgen is a third-party tool that can convert MBR to GPT when MBR2GPT fails or is unsupported (e.g., on older systems or modified layouts). However, it does not set up the bootloader automatically.
Key Points:
- You must manually recreate the EFI partition.
- You must manually install the UEFI bootloader using bcdboot after conversion.
- Proceed only if MBR2GPT is not usable.
- Always follow up gptgen with Step 4: Rebuild BCD Bootloader.
3. πΌ Cleaning Up Partitions With DiskPart
Target Partition Layout (PostβConversion):
Partition | Purpose | Size |
---|---|---|
1 | EFI System | 4 GB |
2 | Recovery | 4 GB |
3 | OS Volume | Remainder |
Why fixed sizes? Microsoft has misaligned these in the past β we standardize to 10 GB for EFI and Recovery to avoid risk.
DiskPart Steps:
diskpart
list disk
select disk 0
list partition
Delete unwanted partitions (OEM, redundant recovery, etc).
Create EFI:
create partition efi size=4096
format quick fs=fat32 label="System"
assign letter=S
Create Recovery (optional):
create partition primary size=4096
format quick fs=ntfs label="Recovery"
assign letter=R
4. π₯Ύ Rebuild BCD Bootloader
bcdboot C:\Windows /s S: /f UEFI
C:
= OS pathS:
= EFI partition/f UEFI
= Force GPT boot
5. π§βπ» REQUIRED: Backup & Remove User Profiles
Before proceeding, you must remove all local user accounts and profiles to avoid SID duplication and profile conflicts. This is required for all images.
π AUTOMATED OPTION: Complete PowerShell Script
For a fully automated approach that handles all cleanup steps (5-9) including the sysprep generalization loop with AppxPackage removal, you can use this comprehensive script:
# Complete-WindowsImagePrep.ps1
# Comprehensive Windows Image Preparation Script
# Combines all cleanup, generalization, and AppxPackage removal steps
# Run as Administrator
param(
[switch]$SkipUserCleanup,
[switch]$SkipAgentCleanup,
[switch]$SkipLogCleanup
)
function Write-StepHeader {
param([string]$Title)
Write-Host ""
Write-Host "=" * 60 -ForegroundColor Cyan
Write-Host $Title -ForegroundColor Cyan
Write-Host "=" * 60 -ForegroundColor Cyan
}
function Write-Success {
param([string]$Message)
Write-Host "β $Message" -ForegroundColor Green
}
function Write-Error {
param([string]$Message)
Write-Host "β $Message" -ForegroundColor Red
}
function Write-Warning {
param([string]$Message)
Write-Host "β $Message" -ForegroundColor Yellow
}
function Write-Info {
param([string]$Message)
Write-Host "βΉ $Message" -ForegroundColor Blue
}
function Confirm-Step {
param([string]$Message)
do {
$response = Read-Host "$Message (y/N)"
$response = $response.ToLower()
} while ($response -notin @('y', 'yes', 'n', 'no', ''))
return ($response -in @('y', 'yes'))
}
function Clear-SysprepLogs {
Write-Info "Clearing Sysprep Panther logs for fresh run..."
try {
$pantherPath = "C:\Windows\System32\Sysprep\Panther"
if (Test-Path $pantherPath) {
Remove-Item -Path "$pantherPath\*" -Force -Recurse -ErrorAction Stop
Write-Success "Cleared Sysprep Panther logs"
} else {
Write-Info "Sysprep Panther directory doesn't exist yet"
}
} catch {
Write-Warning "Could not clear Sysprep logs: $_"
}
}
function Remove-UserAccountsAndProfiles {
if ($SkipUserCleanup) {
Write-Warning "Skipping user cleanup (parameter specified)"
return
}
Write-StepHeader "STEP 1: Remove User Accounts and Profiles"
Write-Info "This will remove all local user accounts and profiles except:"
Write-Info "β’ System accounts (Administrator, Guest, etc.)"
Write-Info "β’ Current user: $([System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1])"
if (-not (Confirm-Step "Continue with user cleanup?")) {
Write-Warning "Skipped user cleanup"
return
}
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1]
$systemAccounts = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount',$currentUser)
$systemProfiles = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount','Public',$currentUser)
# Remove user accounts
Write-Info "Removing local user accounts..."
$users = Get-LocalUser | Where-Object { $_.Name -notin $systemAccounts }
if ($users.Count -eq 0) {
Write-Success "No additional user accounts found"
} else {
foreach ($user in $users) {
try {
Remove-LocalUser -Name $user.Name -ErrorAction Stop
Write-Success "Removed user: $($user.Name)"
} catch {
Write-Error "Failed to remove user: $($user.Name) - $_"
}
}
}
# Remove user profiles
Write-Info "Removing local user profiles..."
$profileFolders = Get-ChildItem -Path 'C:\Users' -Directory | Where-Object {
$_.Name -notin $systemProfiles
}
if ($profileFolders.Count -eq 0) {
Write-Success "No additional user profiles found"
} else {
foreach ($folder in $profileFolders) {
try {
# Remove profile registry entry
$profileSid = (Get-CimInstance Win32_UserProfile | Where-Object { $_.LocalPath -eq $folder.FullName }).SID
if ($profileSid) {
Remove-CimInstance -InputObject (Get-CimInstance Win32_UserProfile | Where-Object { $_.SID -eq $profileSid }) -ErrorAction SilentlyContinue
Write-Info "Removed registry entry for: $($folder.Name)"
}
# Force remove profile folder
Remove-Item -Path $folder.FullName -Recurse -Force -ErrorAction Stop
Write-Success "Removed profile folder: $($folder.FullName)"
} catch {
Write-Error "Failed to remove profile: $($folder.FullName) - $_"
}
}
}
}
function Clear-AgentIdentityData {
if ($SkipAgentCleanup) {
Write-Warning "Skipping agent cleanup (parameter specified)"
return
}
Write-StepHeader "STEP 2: Clear Agent Identity Data"
Write-Info "This will clear identity data for:"
Write-Info "β’ NinjaRMM (NodeId registry value and data folder)"
Write-Info "β’ Veeam (registry keys and data folder)"
Write-Info "β’ Services will be preserved"
if (-not (Confirm-Step "Continue with agent cleanup?")) {
Write-Warning "Skipped agent cleanup"
return
}
# Remove NinjaRMM NodeId
try {
Remove-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\NinjaRMM LLC\NinjaRMMAgent\Agent' -Name 'NodeId' -Force -ErrorAction Stop
Write-Success "Removed NinjaRMM NodeId registry value"
} catch {
Write-Info "NinjaRMM NodeId registry value not found or already removed"
}
# Remove Veeam registry key
try {
Remove-Item -Path 'HKLM:\SOFTWARE\Veeam' -Recurse -Force -ErrorAction Stop
Write-Success "Removed Veeam registry key"
} catch {
Write-Info "Veeam registry key not found or already removed"
}
# Remove NinjaRMM folder
try {
Remove-Item -Path 'C:\ProgramData\NinjaRMMAgent' -Recurse -Force -ErrorAction Stop
Write-Success "Removed NinjaRMM data folder"
} catch {
Write-Info "NinjaRMM data folder not found or already removed"
}
# Remove Veeam folder
try {
Remove-Item -Path 'C:\ProgramData\Veeam' -Recurse -Force -ErrorAction Stop
Write-Success "Removed Veeam data folder"
} catch {
Write-Info "Veeam data folder not found or already removed"
}
}
function Disable-BitLockerVolumes {
Write-StepHeader "STEP 3: Disable BitLocker"
Write-Info "This will disable BitLocker on all encrypted volumes"
Write-Warning "BitLocker decryption may take time depending on drive size"
if (-not (Confirm-Step "Continue with BitLocker disable?")) {
Write-Warning "Skipped BitLocker disable"
return
}
try {
# Get all BitLocker volumes
$bitlockerVolumes = Get-BitLockerVolume -ErrorAction SilentlyContinue
if (-not $bitlockerVolumes) {
Write-Success "No BitLocker volumes found"
return
}
$encryptedVolumes = $bitlockerVolumes | Where-Object {
$_.VolumeStatus -eq 'FullyEncrypted' -or
$_.VolumeStatus -eq 'EncryptionInProgress' -or
$_.VolumeStatus -eq 'DecryptionInProgress'
}
if ($encryptedVolumes.Count -eq 0) {
Write-Success "No encrypted BitLocker volumes found"
return
}
Write-Info "Found $($encryptedVolumes.Count) encrypted BitLocker volume(s):"
foreach ($volume in $encryptedVolumes) {
Write-Info " β’ $($volume.MountPoint) - Status: $($volume.VolumeStatus)"
}
foreach ($volume in $encryptedVolumes) {
try {
Write-Info "Disabling BitLocker on volume: $($volume.MountPoint)"
Disable-BitLocker -MountPoint $volume.MountPoint -ErrorAction Stop
Write-Success "BitLocker disable initiated for: $($volume.MountPoint)"
} catch {
Write-Error "Failed to disable BitLocker on $($volume.MountPoint): $_"
}
}
# Check if any volumes are still decrypting
$decryptingVolumes = Get-BitLockerVolume | Where-Object {
$_.VolumeStatus -eq 'DecryptionInProgress'
}
if ($decryptingVolumes.Count -gt 0) {
Write-Warning "BitLocker decryption is in progress on $($decryptingVolumes.Count) volume(s)"
Write-Info "Decryption will continue in the background"
Write-Info "You can check status with: Get-BitLockerVolume"
}
} catch {
Write-Error "Failed to process BitLocker volumes: $_"
Write-Info "BitLocker management may not be available on this system"
}
}
function Clear-WindowsLogs {
if ($SkipLogCleanup) {
Write-Warning "Skipping log cleanup (parameter specified)"
return
}
Write-StepHeader "STEP 4: Clear Windows Logs"
Write-Info "This will remove:"
Write-Info "β’ Windows Event Logs"
Write-Info "β’ Panther setup logs"
if (-not (Confirm-Step "Continue with log cleanup?")) {
Write-Warning "Skipped log cleanup"
return
}
# Remove Windows event logs
try {
Remove-Item -Path 'C:\Windows\System32\winevt\Logs\*' -Force -ErrorAction Stop
Write-Success "Removed Windows event logs"
} catch {
Write-Error "Failed to remove Windows event logs: $_"
}
# Remove Panther logs
try {
Remove-Item -Path "$env:SystemRoot\Panther\*" -Force -ErrorAction Stop
Write-Success "Removed Panther logs"
} catch {
Write-Error "Failed to remove Panther logs: $_"
}
}
function Create-UnattendXml {
Write-StepHeader "STEP 5: Create unattend.xml"
$unattendPath = 'C:\Windows\System32\Sysprep\unattend.xml'
Write-Info "Creating unattend.xml at: $unattendPath"
if (-not (Confirm-Step "Continue with unattend.xml creation?")) {
Write-Warning "Skipped unattend.xml creation"
return $false
}
$unattendContent = @'
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<AutoLogon>
<Username>installadmin</Username>
<Password>
<Value>DTC@dental2025</Value>
<PlainText>true</PlainText>
</Password>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
</AutoLogon>
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
</OOBE>
<TimeZone>Eastern Standard Time</TimeZone>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>installadmin</Name>
<Group>Administrators</Group>
<Password>
<Value>DTC@dental2025</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-ApplicationExperience"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<AITEnable>false</AITEnable>
</component>
<component name="Microsoft-Windows-ErrorReportingCore"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<DisableWerReporting>true</DisableWerReporting>
</component>
</settings>
</unattend>
'@
try {
$unattendContent | Set-Content -Path $unattendPath -Encoding UTF8 -ErrorAction Stop
Write-Success "unattend.xml created successfully"
return $true
} catch {
Write-Error "Failed to create unattend.xml: $_"
return $false
}
}
function Get-AppxBlockers {
$logPath = 'C:\Windows\System32\Sysprep\Panther\setuperr.log'
if (!(Test-Path $logPath)) {
return @()
}
try {
$content = Get-Content $logPath -Raw -ErrorAction Stop
# Match the actual sysprep error format for AppxPackages
# Example: "SYSPRP Package Microsoft.WidgetsPlatformRuntime_1.6.2.0_arm64__8wekyb3d8bbwe was installed for a user, but not provisioned for all users"
$matches = [regex]::Matches($content, 'SYSPRP Package ([^\s]+) was installed for a user, but not provisioned for all users')
$blockers = @()
foreach ($match in $matches) {
$packageFullName = $match.Groups[1].Value
# Extract just the package family name (everything before the first underscore + architecture + hash)
# Example: Microsoft.WidgetsPlatformRuntime_1.6.2.0_arm64__8wekyb3d8bbwe -> Microsoft.WidgetsPlatformRuntime
if ($packageFullName -match '^([^_]+)') {
$blockers += $Matches[1]
} else {
# Fallback: use the full package name
$blockers += $packageFullName
}
}
# Also check for the older error format as fallback
$legacyMatches = [regex]::Matches($content, 'Package (.*?) cannot be removed and is preventing Sysprep')
foreach ($match in $legacyMatches) {
$blockers += $match.Groups[1].Value
}
return ($blockers | Select-Object -Unique)
} catch {
Write-Error "Failed to read setuperr.log: $_"
return @()
}
}
function Remove-AppxBlockers {
param([array]$Blockers)
if ($Blockers.Count -eq 0) {
return
}
Write-Info "Removing $($Blockers.Count) AppxPackage blocker(s):"
foreach ($pkg in $Blockers) {
Write-Info " β’ $pkg"
try {
Get-AppxPackage -AllUsers "*$pkg*" | Remove-AppxPackage -AllUsers -ErrorAction SilentlyContinue
Remove-AppxProvisionedPackage -Online -PackageName "*$pkg*" -ErrorAction SilentlyContinue
Write-Success "Attempted removal of $pkg"
} catch {
Write-Error "Failed to remove $pkg: $_"
}
}
}
function Start-SysprepGeneralization {
Write-StepHeader "STEP 6: Sysprep Generalization Loop"
$unattendPath = 'C:\Windows\System32\Sysprep\unattend.xml'
Write-Warning "IMPORTANT: Create a VM checkpoint/snapshot before proceeding!"
Write-Info "This step will:"
Write-Info "β’ Run sysprep /generalize /oobe /shutdown"
Write-Info "β’ Check for AppxPackage blockers after each failure"
Write-Info "β’ Remove problematic packages automatically"
Write-Info "β’ Repeat until successful"
if (-not (Confirm-Step "Ready to start sysprep generalization?")) {
Write-Warning "Sysprep generalization cancelled"
return
}
$attempt = 1
$maxAttempts = 10
while ($attempt -le $maxAttempts) {
Write-StepHeader "Sysprep Attempt #$attempt"
# Clear previous logs
Clear-SysprepLogs
Write-Info "Running: sysprep /generalize /oobe /shutdown /unattend:$unattendPath"
Write-Warning "The system will attempt to shut down after sysprep completes..."
if (-not (Confirm-Step "Continue with sysprep attempt #$attempt?")) {
Write-Warning "Sysprep attempt cancelled"
return
}
# Run sysprep
try {
$sysprepProcess = Start-Process -FilePath "C:\Windows\System32\Sysprep\sysprep.exe" `
-ArgumentList "/generalize", "/oobe", "/shutdown", "/unattend:$unattendPath" `
-Wait -PassThru -WindowStyle Hidden
$exitCode = $sysprepProcess.ExitCode
# If we reach this point, sysprep returned control to us
# With /shutdown parameter, sysprep should shut down the system if successful
# If we're still running, it means sysprep failed
Write-Error "Sysprep failed - system did not shut down (exit code: $exitCode)"
# Give a moment for logs to be written
Start-Sleep -Seconds 3
} catch {
Write-Error "Failed to run sysprep: $_"
}
# Check for AppxPackage blockers
Write-Info "Checking for AppxPackage blockers..."
Start-Sleep -Seconds 2 # Give log time to be written
$blockers = Get-AppxBlockers
if ($blockers.Count -eq 0) {
Write-Error "Sysprep failed but no AppxPackage blockers found in log"
Write-Info "Check C:\Windows\System32\Sysprep\Panther\setuperr.log for details"
if (Confirm-Step "Try sysprep again anyway?") {
$attempt++
continue
} else {
Write-Error "Sysprep generalization failed after $attempt attempts"
return
}
}
Write-Warning "Found $($blockers.Count) AppxPackage blocker(s):"
foreach ($blocker in $blockers) {
Write-Warning " β’ $blocker"
}
Remove-AppxBlockers -Blockers $blockers
Write-Info "AppxPackage removal complete"
if ($attempt -lt $maxAttempts) {
Write-Info "Preparing for next sysprep attempt..."
$attempt++
} else {
Write-Error "Maximum attempts ($maxAttempts) reached. Manual intervention required."
break
}
}
Write-Error "Sysprep generalization did not complete successfully"
Write-Info "Check logs at: C:\Windows\System32\Sysprep\Panther\setuperr.log"
}
# Main execution
function Main {
Write-StepHeader "Windows Image Preparation Script"
Write-Info "This script will prepare a Windows image for generalization"
Write-Info "Run as Administrator in the VM you want to generalize"
Write-Warning "Create a VM checkpoint/snapshot before running!"
if (-not (Confirm-Step "Continue with image preparation?")) {
Write-Warning "Image preparation cancelled"
exit 0
}
# Check if running as administrator
$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator")
if (-not $isAdmin) {
Write-Error "This script must be run as Administrator"
exit 1
}
try {
# Execute all steps
Remove-UserAccountsAndProfiles
Clear-AgentIdentityData
Disable-BitLockerVolumes
Clear-WindowsLogs
if (Create-UnattendXml) {
Start-SysprepGeneralization
} else {
Write-Error "Cannot proceed without unattend.xml"
exit 1
}
} catch {
Write-Error "Script execution failed: $_"
exit 1
}
}
# Run main function
Main
Usage:
# Run as Administrator
.\Complete-WindowsImagePrep.ps1
# Optional parameters to skip certain steps
.\Complete-WindowsImagePrep.ps1 -SkipUserCleanup -SkipAgentCleanup -SkipLogCleanup
This script automates all the manual steps below (sections 5-9) with interactive confirmations, automatic retry logic for sysprep failures, and comprehensive AppxPackage removal.
π§ MANUAL OPTION: Individual Steps
If you prefer to run each step manually or need to troubleshoot specific issues, follow the individual steps below:
Step 1: Backup Profiles with ProfWiz (if needed)
Use ForensiT User Profile Wizard (ProfWiz) to back up profiles if you need to preserve any data.
Step 2: Delete Local User Accounts and Profiles (PowerShell)
# Get the current user
$currentUser = [System.Security.Principal.WindowsIdentity]::GetCurrent().Name.Split('\')[-1]
# Remove local user accounts except system accounts and the current user
$users = Get-LocalUser | Where-Object {
$_.Name -notin @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount',$currentUser)
}
foreach ($user in $users) {
Remove-LocalUser -Name $user.Name
}
# Remove user profiles except system accounts and the current user
$systemProfiles = @('Administrator','DefaultAccount','Guest','WDAGUtilityAccount','Public',$currentUser)
$profileFolders = Get-ChildItem -Path 'C:\Users' -Directory | Where-Object {
$_.Name -notin $systemProfiles
}
foreach ($folder in $profileFolders) {
try {
# Remove profile registry entry
$profileSid = (Get-CimInstance Win32_UserProfile | Where-Object { $_.LocalPath -eq $folder.FullName }).SID
if ($profileSid) {
Remove-CimInstance -InputObject (Get-CimInstance Win32_UserProfile | Where-Object { $_.SID -eq $profileSid }) -ErrorAction SilentlyContinue
}
# Force remove profile folder
Remove-Item -Path $folder.FullName -Recurse -Force -ErrorAction Stop
Write-Host "Removed profile: $($folder.FullName)" -ForegroundColor Green
} catch {
Write-Host "Failed to remove profile: $($folder.FullName) - $_" -ForegroundColor Red
}
}
β οΈ Permanently deletes all local user accounts and profiles except system accounts and the currently signed-in user.
6. πΎ REQUIRED: Skip OOBE with unattend.xml
You must use an unattend.xml to skip setup UI and telemetry for all images.
Sample unattend.xml Snippet
<?xml version="1.0" encoding="utf-8"?>
<unattend xmlns="urn:schemas-microsoft-com:unattend"
xmlns:wcm="http://schemas.microsoft.com/WMIConfig/2002/State"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<settings pass="oobeSystem">
<component name="Microsoft-Windows-Shell-Setup"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<AutoLogon>
<Username>installadmin</Username>
<Password>
<Value>DTC@dental2025</Value>
<PlainText>true</PlainText>
</Password>
<Enabled>true</Enabled>
<LogonCount>1</LogonCount>
</AutoLogon>
<OOBE>
<HideEULAPage>true</HideEULAPage>
<HideWirelessSetupInOOBE>true</HideWirelessSetupInOOBE>
<NetworkLocation>Work</NetworkLocation>
<ProtectYourPC>3</ProtectYourPC>
<SkipMachineOOBE>true</SkipMachineOOBE>
<SkipUserOOBE>true</SkipUserOOBE>
</OOBE>
<TimeZone>Eastern Standard Time</TimeZone>
<UserAccounts>
<LocalAccounts>
<LocalAccount wcm:action="add">
<Name>installadmin</Name>
<Group>Administrators</Group>
<Password>
<Value>DTC@dental2025</Value>
<PlainText>true</PlainText>
</Password>
</LocalAccount>
</LocalAccounts>
</UserAccounts>
</component>
</settings>
<settings pass="specialize">
<component name="Microsoft-Windows-ApplicationExperience"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<AITEnable>false</AITEnable>
</component>
<component name="Microsoft-Windows-ErrorReportingCore"
processorArchitecture="amd64"
publicKeyToken="31bf3856ad364e35"
language="neutral"
versionScope="nonSxS">
<DisableWerReporting>true</DisableWerReporting>
</component>
</settings>
</unattend>
7. π Optional (But Highly Recommended) Pre-Generalization Cleanup
Before generalization, consider performing the following steps to ensure a clean, reusable image. These are optional but highly recommended for images that will be widely distributed.
π Clear Agent Identity & Token Data (Preserve Services, PowerShell)
Tool | Remove From Registry | Clear File System Path |
---|---|---|
NinjaRMM | HKLM\SOFTWARE\WOW6432Node\NinjaRMM LLC\NinjaRMMAgent\Agent (NodeId DWORD) |
C:\ProgramData\NinjaRMMAgent |
Veeam Agent | HKLM\SOFTWARE\Veeam\Veeam Endpoint Backup |
C:\ProgramData\Veeam |
# Remove NinjaRMM NodeId registry value
Remove-ItemProperty -Path 'HKLM:\SOFTWARE\WOW6432Node\NinjaRMM LLC\NinjaRMMAgent\Agent' -Name 'NodeId' -Force -ErrorAction SilentlyContinue
# Remove Veeam registry key
Remove-Item -Path 'HKLM:\SOFTWARE\Veeam' -Recurse -Force -ErrorAction SilentlyContinue
# Remove NinjaRMM and Veeam folders
Remove-Item -Path 'C:\ProgramData\NinjaRMMAgent' -Recurse -Force -ErrorAction SilentlyContinue
Remove-Item -Path 'C:\ProgramData\Veeam' -Recurse -Force -ErrorAction SilentlyContinue
β Do not delete agent service keys under HKLM\SYSTEM\CurrentControlSet\Services
.
π₯΅ Windows Log Cleanup (PowerShell)
# Remove Windows event logs
Remove-Item -Path 'C:\Windows\System32\winevt\Logs\*' -Force -ErrorAction SilentlyContinue
# Remove Panther logs
Remove-Item -Path "$env:SystemRoot\Panther\*" -Force -ErrorAction SilentlyContinue
8. πΈ Create VM Checkpoint Before Generalization
β οΈ CRITICAL STEP: After all cleanup and preparation, create a checkpoint/snapshot of your VM. This allows you to restore and retry if generalization fails.
Steps:
- In your hypervisor management console, create a checkpoint/snapshot
- Name it appropriately (e.g., "Pre-Sysprep-Checkpoint")
- This allows you to restore and retry if sysprep fails
9. π― Generalization (Sysprep) and Appx Package Removal (Final Steps)
Step 1: Run Generalization (Sysprep)
sysprep /generalize /oobe /shutdown /unattend:C:\Windows\System32\Sysprep\unattend.xml
Expected Outcome: Sysprep may fail on the first attempt due to problematic packages. This is normal and expected.
Step 2: If Sysprep Fails, Remove Problematic Windows Packages
After a failed sysprep attempt, analyze the logs and remove problematic packages.
Review setuperr.log
Open:
C:\Windows\System32\Sysprep\Panther\setuperr.log
Look for entries such as:
Package <Name> cannot be removed and is preventing Sysprep
Remove Identified Packages via PowerShell
β Include asterisks (*) before and after the package name for fuzzy matching.
Get-AppxPackage -AllUsers *PackageName* | Remove-AppxPackage -AllUsers
If Get-AppxPackage does not list the package, use DISM:
dism /Online /Remove-ProvisionedAppxPackage /PackageName:*PackageName*
You can script removal for multiple packages by parsing the log.
π Run from an elevated PowerShell session and verify success before continuing.
Retry Generalization
After removing problematic packages, run sysprep again:
sysprep /generalize /oobe /shutdown /unattend:C:\Windows\System32\Sysprep\unattend.xml
Repeat this process until sysprep completes successfully without errors.
β Final Checklist
- Booted offline on hypervisor
- Converted to GPT with valid partitions
- Optional: user profiles backed up and deleted
- Agent identities reset (binaries remain)
- Problematic packages removed based on setuperr.log
- unattend.xml disables OOBE + telemetry
- Sysprep executed successfully
π Run from an elevated PowerShell session and verify success before continuing.
Retry Generalization
After removing problematic packages, run sysprep again:
sysprep /generalize /oobe /shutdown /unattend:C:\Windows\System32\Sysprep\unattend.xml
Repeat this process until sysprep completes successfully without errors.
β
Final Checklist
Booted offline on hypervisorConverted to GPT with valid partitionsOptional: user profiles backed up and deletedAgent identities reset (binaries remain)Problematic packages removed based on setuperr.logunattend.xml disables OOBE + telemetrySysprep executed successfully