Building a Windows 365 Custom Image

The one article that every Windows 365 evangelist needs is building custom images. Arguably, its one of the most important aspects to your Windows 365 deployment. That Windows 365 custom image will set the tone for your entire user experience. Today, we will cover building the custom image and deploying it via provisioning policies.

Creating the Windows 365 Custom Image

For us to build a successful image, we have a few areas of focus:

  • Building the VM with a Gallery Image
  • Applying optimizations
  • Installing your baseline apps/settings/configurations
  • SysPrep

We will go into each of these now in detail. Luckily, there are some nice shortcuts to get you there.

Deploying a VM to serve as the Gold Image

First, we are going to go into Virtual Machines and create an Azure Virtual Machine.

After picking my resource group, I select “See all images” so I can find the image to base my Windows 365 build off of.

You will want to select the “Windows 365 Cloud PC image template” as your starting point. Select the “Select” drop down

You will want to select the Microsoft 365 apps version since we will apply the optimizations ourself:

We continue with creating the virtual machine following your basics (e.g. creating admin account, no public ports opened, etc).

For disks, I recommend using a Standard SSD:

For networking, I go with a basic setup, and will use my production VMs to RDP to it instead of spinning up Public IPs:

Now, at this point I will have it create my VM so that I can move onto installing applications.

Installing my Baseline Apps

This entire section is basically up to you and how you want to do things. Basically, you need to RDP onto the new VM and install the apps that you want in your custom image.

Some of the items you might want are:

  • VDI-optimized collaboration clients e.g. Zoom for VDI
  • Key applications that everyone should be getting like Okta Verify for example

You can see I have installed a few applications to ensure that I am getting what my users need:

Optimizing my Windows 365 Image

Anyone who has been doing VDI or anything similar to it is quite familiar with the ardous task of optimizing their image. Luckily, the Virtual Desktop Team has built a nice tool that will help optimize your image for you now.

The process is fairly simple. You download the tool from the link above, and start customizing what you actually want to disable. In full transparency, some people have had some issues leveraging the tool and it breaking AVD images. I decided to be a bit more pragmatic. Conceptually, it’s not too confusing.

You have a collection of JSON files:

These JSON files correlate to commands you pass their powershell script. VDOT focuses on these items:

  • Appx Packages
  • Autologgers
  • Default User Settings
  • Disk Cleanup
  • LGPO (Local Group Policy)
  • Network Optimizations
  • Scheduled Tasks
  • Services
  • Windows Media Player

I decided to go through each of the items and look at them deeper to see if it makes sense. Let’s start by checking out Appx Packages.

A Closer Look at Appx Packages and Optimization

First, let’s look at what the code does. Then, we will look at the JSON. The code is basic. It will look at the JSON file and anything marked for disabled it will use the Remove CMDlets that many of us are familiar with:

                        Write-EventLog -EventId 20 -Message "Removing Provisioned Package $($Item.AppxPackage)" -LogName 'Virtual Desktop Optimization' -Source 'AppxPackages' -EntryType Information 
                        Write-Verbose "Removing Provisioned Package $($Item.AppxPackage)"
                        Get-AppxProvisionedPackage -Online | Where-Object { $_.PackageName -like ("*{0}*" -f $Item.AppxPackage) } | Remove-AppxProvisionedPackage -Online -ErrorAction SilentlyContinue | Out-Null
                        Write-EventLog -EventId 20 -Message "Attempting to remove [All Users] $($Item.AppxPackage) - $($Item.Description)" -LogName 'Virtual Desktop Optimization' -Source 'AppxPackages' -EntryType Information 
                        Write-Verbose "Attempting to remove [All Users] $($Item.AppxPackage) - $($Item.Description)"
                        Get-AppxPackage -AllUsers -Name ("*{0}*" -f $Item.AppxPackage) | Remove-AppxPackage -AllUsers -ErrorAction SilentlyContinue 
                        Write-EventLog -EventId 20 -Message "Attempting to remove $($Item.AppxPackage) - $($Item.Description)" -LogName 'Virtual Desktop Optimization' -Source 'AppxPackages' -EntryType Information 
                        Write-Verbose "Attempting to remove $($Item.AppxPackage) - $($Item.Description)"
                        Get-AppxPackage -Name ("*{0}*" -f $Item.AppxPackage) | Remove-AppxPackage -ErrorAction SilentlyContinue | Out-Null

Now, let’s examine our JSON. The code is pretty straight forward I think. You just change “VDIState” to disabled and it will remove it for you. The example JSON is below. Basically, I reviewed each item and disabled the ones that I don’t think people need:

    "AppxPackage": "Microsoft.BingNews",
    "VDIState": "Unchanged",
    "URL": "",
    "Description": "Microsoft News app"
    "AppxPackage": "Microsoft.BingWeather",
    "VDIState": "Unchanged",
    "URL": "",
    "Description": "MSN Weather app"
    "AppxPackage": "Microsoft.GamingApp",
    "VDIState": "Unchanged",
    "URL": "",
    "Description": "Xbox app"
    "AppxPackage": "Microsoft.GetHelp",
    "VDIState": "Unchanged",
    "URL": "",
    "Description": "App that facilitates free support for Microsoft products"

A Closer Look at Autologgers and Optimization

First, let’s look at what the code does. Then, we will look at the JSON. The code is basic. It will look at the JSON file and anything marked for disabled it will add some reg keys:

New-ItemProperty -Path ("{0}" -f $Item.KeyName) -Name "Start" -PropertyType "DWORD" -Value 0 -Force -ErrorAction Stop | Out-Null

The autologgers aren’t too substantial. There’s 7 of them:

  • Cellcore
  • ReadyBoot
  • WDIContextLog
  • WiFiDriverIHVSession
  • WiFiSession
  • ReFSLog
  • Mellanox-Kernel

For the most part these aren’t in scope. A few of them require ReFs, some stuff that is WiFi specific (that’s self explanatory).

The only one that I recommend turning on for Windows 365 is ReadyBoot which is not relevant for non-persistent but could be for Windows 365.

A Closer Look at Default User Settings and Optimization

First, let’s look at what the code does. Then, we will look at the JSON. The code is a bit longer than with autologgers. It will look at the JSON file and anything marked for set property true it will either set the registry key or create a new one:

If (Get-ItemProperty -Path ("{0}" -f $Item.HivePath) -ErrorAction SilentlyContinue)
                            Write-EventLog -EventId 40 -Message "Set $($Item.HivePath) - $Value" -LogName 'Virtual Desktop Optimization' -Source 'DefaultUserSettings' -EntryType Information
                            Set-ItemProperty -Path ("{0}" -f $Item.HivePath) -Name $Item.KeyName -Value $Value -Type $Item.PropertyType -Force 
                            Write-EventLog -EventId 40 -Message "New $($Item.HivePath) Name $($Item.KeyName) PropertyType $($Item.PropertyType) Value $Value" -LogName 'Virtual Desktop Optimization' -Source 'DefaultUserSettings' -EntryType Information
                            New-ItemProperty -Path ("{0}" -f $Item.HivePath) -Name $Item.KeyName -PropertyType $Item.PropertyType -Value $Value -Force | Out-Null

Basically, the default user settings tweak a few areas:

  • Explorer Settings (ShellState, IconsOnly, ListviewAlphaSelect, ListviewShadow, ShowCompColor, ShowInfoTip, TaskbarAnimations, VisualFXSetting)
  • Desktop Windows Manager Settings (EnableAeroPeek, Always HiberNateThumbnails)
  • Desktop Settings (DragFullWindows, FontSmoothing, UserPreferencesMask)
  • Windows Metrics (MinAnimate)
  • Storage Policies for StorageSense
  • ContentDeliveryManager (SystemPaneSuggestionsEnabled)
  • User Profile (HttpAcceptLanguageOptOut)
  • Background Access Applications (Photos, Skype, YourPhone, Edge)
  • Inking and Typing Personalization
  • Input Personalization (RestrictImplicitTextCollection, RestrictImplicitInkCollection, TrainedDataStore HarvestContacts, UserProfileEngagement ScoobeSystemSettingEnabled)

These are common best practices so I left them all in. No major worries there.

A Closer Look at Disk Cleanup

This one is a no brainer and relatively simple. They delete certain items basically as you can see below:

            Write-EventLog -EventId 90 -Message "Removing .tmp, .etl, .evtx, thumbcache*.db, *.log files not in use" -LogName 'Virtual Desktop Optimization' -Source 'DiskCleanup' -EntryType Information
            Write-Host "Removing .tmp, .etl, .evtx, thumbcache*.db, *.log files not in use"
            Get-ChildItem -Path c:\ -Include *.tmp, *.dmp, *.etl, *.evtx, thumbcache*.db, *.log -File -Recurse -Force -ErrorAction SilentlyContinue | Remove-Item -ErrorAction SilentlyContinue

            # Delete "RetailDemo" content (if it exits)
            Write-EventLog -EventId 90 -Message "Removing Retail Demo content (if it exists)" -LogName 'Virtual Desktop Optimization' -Source 'DiskCleanup' -EntryType Information
            Write-Host "Removing Retail Demo content (if it exists)"
            Get-ChildItem -Path $env:ProgramData\Microsoft\Windows\RetailDemo\* -Recurse -Force -ErrorAction SilentlyContinue | Remove-Item -Recurse -ErrorAction SilentlyContinue

            # Delete not in-use anything in the C:\Windows\Temp folder
            Write-EventLog -EventId 90 -Message "Removing all files not in use in $env:windir\TEMP" -LogName 'Virtual Desktop Optimization' -Source 'DiskCleanup' -EntryType Information
            Write-Host "Removing all files not in use in $env:windir\TEMP"
            Remove-Item -Path $env:windir\Temp\* -Recurse -Force -ErrorAction SilentlyContinue -Exclude packer*.ps1

            # Clear out Windows Error Reporting (WER) report archive folders
            Write-EventLog -EventId 90 -Message "Cleaning up WER report archive" -LogName 'Virtual Desktop Optimization' -Source 'DiskCleanup' -EntryType Information
            Write-Host "Cleaning up WER report archive"
            Remove-Item -Path $env:ProgramData\Microsoft\Windows\WER\Temp\* -Recurse -Force -ErrorAction SilentlyContinue
            Remove-Item -Path $env:ProgramData\Microsoft\Windows\WER\ReportArchive\* -Recurse -Force -ErrorAction SilentlyContinue
            Remove-Item -Path $env:ProgramData\Microsoft\Windows\WER\ReportQueue\* -Recurse -Force -ErrorAction SilentlyContinue

            # Delete not in-use anything in your %temp% folder
            Write-EventLog -EventId 90 -Message "Removing files not in use in $env:temp directory" -LogName 'Virtual Desktop Optimization' -Source 'DiskCleanup' -EntryType Information
            Write-Host "Removing files not in use in $env:temp directory"
            Remove-Item -Path $env:TEMP\* -Recurse -Force -ErrorAction SilentlyContinue -Exclude packer*.ps1

            # Clear out ALL visible Recycle Bins
            Write-EventLog -EventId 90 -Message "Clearing out ALL Recycle Bins" -LogName 'Virtual Desktop Optimization' -Source 'DiskCleanup' -EntryType Information
            Write-Host "Clearing out ALL Recycle Bins"
            Clear-RecycleBin -Force -ErrorAction SilentlyContinue

            # Clear out BranchCache cache
            Write-EventLog -EventId 90 -Message "Clearing BranchCache cache" -LogName 'Virtual Desktop Optimization' -Source 'DiskCleanup' -EntryType Information
            Write-Host "Clearing BranchCache cache" 
            Clear-BCCache -Force -ErrorAction SilentlyContinue

A Closer Look at LGPO and Optimization

First, let’s look at what the code does. Then, we will look at the JSON. This one I’m a bit conflicted on as it relies on local GPO. You should ONLY use this if you’re doing Azure AD Join devices. If you’re domain-joined I’d suggest configuring these at the GPO-level. The code works the same way for the most part as default user settings:

If (Get-ItemProperty -Path $Key.RegItemPath -Name $Key.RegItemValueName -ErrorAction SilentlyContinue) 
                            Write-EventLog -EventId 80 -Message "Found key, $($Key.RegItemPath) Name $($Key.RegItemValueName) Value $($Key.RegItemValue)" -LogName 'Virtual Desktop Optimization' -Source 'LGPO' -EntryType Information
                            Write-Verbose "Found key, $($Key.RegItemPath) Name $($Key.RegItemValueName) Value $($Key.RegItemValue)"
                            Set-ItemProperty -Path $Key.RegItemPath -Name $Key.RegItemValueName -Value $Key.RegItemValue -Force 
                            If (Test-path $Key.RegItemPath)
                                Write-EventLog -EventId 80 -Message "Path found, creating new property -Path $($Key.RegItemPath) -Name $($Key.RegItemValueName) -PropertyType $($Key.RegItemValueType) -Value $($Key.RegItemValue)" -LogName 'Virtual Desktop Optimization' -Source 'LGPO' -EntryType Information
                                Write-Verbose "Path found, creating new property -Path $($Key.RegItemPath) Name $($Key.RegItemValueName) PropertyType $($Key.RegItemValueType) Value $($Key.RegItemValue)"
                                New-ItemProperty -Path $Key.RegItemPath -Name $Key.RegItemValueName -PropertyType $Key.RegItemValueType -Value $Key.RegItemValue -Force | Out-Null 
                                Write-EventLog -EventId 80 -Message "Creating Key and Path" -LogName 'Virtual Desktop Optimization' -Source 'LGPO' -EntryType Information
                                Write-Verbose "Creating Key and Path"
                                New-Item -Path $Key.RegItemPath -Force | New-ItemProperty -Name $Key.RegItemValueName -PropertyType $Key.RegItemValueType -Value $Key.RegItemValue -Force | Out-Null 

LGPO sets a TON of settings. The JSON file itself is 1000 lines. (That would make it like 100+ settings in there). I recommend looking through the file itself to see how you want to run with it. The file is called PolicyRegSettings.json. The settings cover many of the troublemakers like BITS, DWM, Edge settings, etc. I decided to roll forward with accepting their base settings.

A Closer Look at Network Optimizations

First, let’s look at what the code does. Then, we will look at the JSON. It’s similar to the others as it will look at the JSON file and anything marked for set property true it will either set the registry key or create a new one:

Foreach ($Key in $Keys)
                                If (Get-ItemProperty -Path $Hive.HivePath -Name $Key.Name -ErrorAction SilentlyContinue)
                                    Write-EventLog -EventId 70 -Message "Setting $($Hive.HivePath) -Name $($Key.Name) -Value $($Key.PropertyValue)" -LogName 'Virtual Desktop Optimization' -Source 'NetworkOptimizations' -EntryType Information
                                    Write-Verbose "Setting $($Hive.HivePath) -Name $($Key.Name) -Value $($Key.PropertyValue)"
                                    Set-ItemProperty -Path $Hive.HivePath -Name $Key.Name -Value $Key.PropertyValue -Force
                                    Write-EventLog -EventId 70 -Message "New $($Hive.HivePath) -Name $($Key.Name) -Value $($Key.PropertyValue)" -LogName 'Virtual Desktop Optimization' -Source 'NetworkOptimizations' -EntryType Information
                                    Write-Host "New $($Hive.HivePath) -Name $($Key.Name) -Value $($Key.PropertyValue)"
                                    New-ItemProperty -Path $Hive.HivePath -Name $Key.Name -PropertyType $Key.PropertyType -Value $Key.PropertyValue -Force | Out-Null

The options are basic, which I accepted all of them:

  • Disable Bandwidth Throttling
  • File Info Cache Entries Max
  • Directory Cache Entries Max
  • File Not Found Cache Entries Max
  • Dormant File Limit

A Closer Look at Scheduled Tasks

This one is simple. It will disable scheduled tasks that are flagged as disabled in the JSON file. It’s just a one-liner:

Disable-ScheduledTask -InputObject $TaskObject | Out-Null

They recommend disabling these tasks (you should review them yourself and make decisions). I shut them all off:

  • AnalyzeSystem (Looks for high energy use)
  • Cellular
  • Consolidator (CEIP item)
  • Diagnostics
  • Family Safety Monitor
  • Family Safety Refresh Task
  • Maps Toast Task
  • Compatibility
  • Windows Disk Diagnostics Data Collector
  • Location Notifications Task
  • Process Memory Diagnostic Events
  • Proxy
  • Queue Reporting
  • Recommended Troubleshooting Scanner
  • Registry Idle Backup Task
  • Run Full Memory Diagnostic
  • Schedule Defrag
  • Speech Model Download Task
  • SQM Tasks
  • SR (Creates regular system protection points)
  • Start Component Cleanup
  • Windows Action Dialog
  • WinSAT
  • Xbox Live Game Save Task
  • Verify WinRE
  • Work Folders Login Synchronization
  • Work Folders Maintenance Work

A Closer Look at Services Optimization

This one is simple. It will disable services that are flagged as disabled in the JSON file. It’s just a one-liner:

Set-Service $Item.Name -StartupType Disabled 

They recommend disabling these tasks (you should review them yourself and make decisions). I shut them all off:

  • Cellular Time Service
  • BcastDVRUserService (a game recording and live broadcast service)
  • Defrag Service
  • Diagnostic Execution Service
  • Diagnostic Policy Service
  • Data Usage Service
  • Windows Mobile Hotspot Service
  • Geolocation Service
  • Downloaded Maps Manager
  • Messaging Service
  • RmSvc (Radio Management and Airplane Mode Service)
  • SEMgrSvc (Payments and NFC/SE Manager)
  • SmsRouter Service
  • SysMain (Maintains and improves system performance over time)
  • VSS
  • WdiSystemHost (Diagnostics System Host service)
  • WerSvc (Windows Error Reporting Service)
  • XblAuthManager
  • XblGameSave
  • XboxGipSvc
  • XboxNetApiSvc

Windows Media Player?

I found it interesting they have an individual item for Windows Media Player. I thought.. “Does this do something special?,” No, it literally just disables it:

            Disable-WindowsOptionalFeature -Online -FeatureName WindowsMediaPlayer -NoRestart | Out-Null
            Get-WindowsPackage -Online -PackageName "*Windows-mediaplayer*" | ForEach-Object { 
                Write-EventLog -EventId 10 -Message "Removing $($_.PackageName)" -LogName 'Virtual Desktop Optimization' -Source 'WindowsMediaPlayer' -EntryType Information 
                Remove-WindowsPackage -PackageName $_.PackageName -Online -ErrorAction SilentlyContinue -NoRestart | Out-Null

Now, I have finished setting up my files and it’s time to run it and watch the magic!

Time to Run the Virtual Desktop Optimization Tool (VDOT)

Running the VDOT scripts are pretty simple. You copy the files to your Windows 365 Image and do the following:

Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope Process
.\Windows_VDOT.ps1 -Optimizations All -AdvancedOptimizations Edge -Verbose -AcceptEULA

As you can see, it’s pretty easy to run once you have modified JSON files and done the preparation work. They also have very helpful documentation on their GitHub that I linked to earlier. Now, we just will run our Windows Updates and move onto the last piece!

SysPrep and Capture our Custom Image

The last piece of things once you are 100% patched is to generalize the VM and capture the image.

You just need to do the following to generalize it:

sysprep.exe /generalize /shutdown /oobe

Once it has been captured, you want to verify it has been shutdown and deallocated. Once that is done, it is time to start the capture process.

You may need to click “Stop” to get it deallocated, but that isn’t abnormal.

Now, click “Capture”

Select “No, capture only a managed image” and give it a name. Once done, you can create the image

Uploading the Custom Image

The first thing you need to do is upload the custom image, which is easy. Navigate to the “Custom Images” tab in Intune and click “Add”

Select the subscription, your image, and specify name and version. Once done, click “Add”

The upload of your new image will likely take awhile to finish. Once done, we can move onto provisioning. You will see this when its done:

Updating our Provisioning Policy

Now, you will go and click on your provisioning policy you want to modify, and click “Edit” next to Image:

Switch to “Custom Image” and “Select” your new Image. Once done click “Next” and “Update”

Once you’re done, you can go into a machine and select “Reprovision” to reprovision with your new custom image

Closing Thoughts

At the end of this process, I am so happy to see this has become much simpler. When I first started doing AVD, creating custom images was very challenging. A big thanks for Christiaan Brinkhoff and the people working on the VDOT project to simplify optimization of the image, which is far less daunting than it has been in the past. A few hours and you too can built a great custom image from scratch to reprovision and only one person wins: The End User.

1 thought on “Building a Windows 365 Custom Image”

  1. Pingback: Weekly Newsletter – 11th of March to 17th of March 2023

Leave a Reply

Scroll to Top
%d bloggers like this: