How to create a Windows Server 2022 (AVD) image with Azure Image Builder and deploy it to Azure Virtual Desktop

In my previous blog post, we’ve gone through the steps to create an offline Windows Server 2022 image on a hyper-v host and upload it to a managed image in Azure. We then uploaded this managed image to a shared image gallery. And this is where our blog post starts today. We will now use this Shared Image to customize it and upload it in the same shared image gallery for further Azure Virtual Desktop usage.

Reference information

As a reference, we will be taking the Microsoft Docs article, which provides guidance on creating a new Shared Image Gallery Image version based on an existing SIG Image version. We will be doing things slightly different as described in the article, and we will be using native ARM templates & PowerShell commands to facilitate our process.


As a prerequisite we need an existing SIG Image Version, you can use the one we created earlier in my previous blog post.

The second prerequisite would be a Managed User Identity. Please follow the guidelines on the following article to create your Managed User Identity account.

Ready, Create, Build

As soon as you’ve got your prerequisites in place, we can retrieve all of the info and store them in variables.
You can find the script in the following Github Repo or review it here.

#region subscription context

$context = Get-AzSubscription | Out-GridView -Title "Select the subscription you want to set in context" -PassThru
Set-AzContext -Subscription $context


#region Variables

# Change this URL if you want other Script or Optimization actions to run
$defaultscripturi = ""

# Change this to the version number you want to create
$imageversion = "6.0.1"

# Retrieve Shared Image Gallery Details

$imagegallery = Get-AzGallery | Out-GridView -Title "Select the SIG you want to use" -PassThru
$imagegallerydefinitioninfo = Get-AzGalleryImageDefinition -GalleryName $imagegallery.Name -ResourceGroupName $imagegallery.ResourceGroupName | Out-GridView -Title "Select the SIG Definition" -PassThru
$imagegalleryinfo = Get-AzGalleryImageVersion -GalleryName $imagegallery.Name -ResourceGroupName $imagegallery.ResourceGroupName -GalleryImageDefinitionName $imagegallerydefinitioninfo.Name | Out-GridView -Title "Select the SIG Image Version" -PassThru

# Retrieve Managed User Identity

$ManagedIdentity = Get-AzUserAssignedIdentity -ResourceGroupName $imagegallery.ResourceGroupName | Out-GridView -Title "Please select the User Assigned Identity" -PassThru


#region Start Create Template

# Create ARM parameters

$armfile = Join-Path "." -ChildPath "ImageManagement" -AdditionalChildPath  "arm", "aib", "azuredeploy.json" | Get-Item
$armparamfile = Join-Path "." -ChildPath "ImageManagement" -AdditionalChildPath  "arm","aib", "azuredeploy.parameters.json" | Get-Item

$armparamobject = Get-Content $armparamfile.FullName | ConvertFrom-Json -AsHashtable
$armparamobject.parameters.ScriptUri.value = $defaultscripturi
$armparamobject.parameters.SigResourceId.value = $imagegallery.Id
$armparamobject.parameters.SigImageDefinition.value = $imagegallerydefinitioninfo.Name
$armparamobject.parameters.SigImageVersion.value = $imageversion
$armparamobject.parameters.UserAssignedId.value = $ManagedIdentity.Id
$armparamobject.parameters.SigSourceImageID.value = $imagegalleryinfo.Id

$paramobject = @{ }
$armparamobject.parameters.keys | ForEach-Object { $paramobject[$_] = $armparamobject.parameters[$_]['value'] }

# Define Image Template Name

$imageTemplateName = $imagegallerydefinitioninfo.Name + "_" + $imageversion

# Deploy ARM Template

$Deploy_HUBWVDImageTemplate = New-AzResourceGroupDeployment -ResourceGroupName $imagegallery.ResourceGroupName -Name $imageTemplateName -TemplateFile $armfile -TemplateParameterObject $paramobject


#region Start Build Template

Start-AzImageBuilderTemplate -ResourceGroupName $imagegallery.ResourceGroupName -Name $imageTemplateName -AsJob

$getStatus=$(Get-AzImageBuilderTemplate -ResourceGroupName $imagegallery.ResourceGroupName -Name $imageTemplateName)

# this shows all the properties
$getStatus | Format-List -Property *

# these show the status the build


Region: Variables

In our variables region, we will be using the following variables. You will receive a couple of out-gridviews to select the right resources for your variables.

  • defaultscripturi = the script you would like to use to optimize and configure the image
  • imageversion = the version number that will be build
  • imagegallery = the Shared Image Gallery that we’ve used for our previous image version
  • imagegallerydefinitioninfo = the Image definition that was created
  • imagegalleryinfo = the version information of your source image
Region: Start Create Template

In this region, we will use the ARM template provided in the original Microsoft Docs and tweak some settings and configurations to match our needs. We will use the ARM template parameter file to update our parameters dynamically and then deploy the template. If you download the entire GitRepo, you should be good to go with all the paths in the script. As soon as the ARM template deployment is finished you will find a new Image Template available for you.

! Quick tip: If you want to speed up the image building process, you can change the default Virtual Machine size, I’ve changed it to a Standard_D8_v3


In the “customize” part of the ARM template is where we define the actions that need to be performed by Azure Image Builder. Here is a small breakdown of the actions that will be performed.

  1. Invoke Optimize Script (Install RDS Session Host role, configure optimizations based on the VDIGuys approach)
  2. Restart (RDS Session host role installation requires a reboot)
  3. Re-try the Optimize Script, for any failed attempts due to a pending reboot, we re-run the optimization script
  4. Install FSLogix (Provided by the Azure Image Builder quick-start templates)
  5. Restart (To make sure to boot in a clean OS state)
  6. Alter the built-in Sysprep script with additional parameters (Thanks to Travis Roberts for providing the required optimizations)
           "customize": [
                        "type": "PowerShell",
                        "runElevated": true,
                        "runAsSystem": true,
                        "name": "settingUpMgmtAgtPath",
                        "inline": [
                            "[concat('$ScriptFromGitHub = Invoke-WebRequest ',parameters('ScriptUri'),' -UseBasicParsing')]",
                            "Invoke-Expression $($ScriptFromGitHub.Content)"
                        "type": "WindowsRestart",
                        "restartTimeout": "30m"
                        "type": "PowerShell",
                        "runElevated": true,
                        "runAsSystem": true,
                        "name": "Retry failed attempts",
                        "inline": [
                            "[concat('$ScriptFromGitHub = Invoke-WebRequest ',parameters('ScriptUri'),' -UseBasicParsing')]",
                            "Invoke-Expression $($ScriptFromGitHub.Content)"
                        "type": "PowerShell",
                        "name": "installFsLogix",
                        "runElevated": true,
                        "runAsSystem": true,
                        "scriptUri": ""
                        "type": "WindowsRestart",
                        "restartTimeout": "30m"
                        "type": "PowerShell",
                        "runElevated": true,
                        "name": "DeprovisioningScript",
                        "inline": [
                            " ((Get-Content -path C:\\DeprovisioningScript.ps1 -Raw) -replace 'Sysprep.exe /oobe /generalize /quiet /quit','Sysprep.exe /oobe /generalize /quit /mode:vm' ) | Set-Content -Path C:\\DeprovisioningScript.ps1"
Image Template Definition
Region: Start Build Template

Now that we have our image template we can start the build phase. As soon as we either click “Start Build” or run the commands in this region, our image template will start building and Azure Image Builder will kick in and do its magic.

The actual build will be performed as a job, as it might take up a reasonable amount of time. You can run the getStatus commands to review the current state or check the Azure Portal Build Run State. Suppose you want to follow up on the customization actions that are being performed. Navigate to the customization log in the storage account created by the Azure Image Builder service (Packer).

Customization Log
Image Template
Image ready!

As soon as the image is ready, you can review the result in your shared image gallery image definition.
(Note: don’t mind the version numbers, it just took me a couple of times to get the right image)

Shared Image Gallery Image Version

Deploy to Azure Virtual Desktop

The image we have just created can now be used to deploy a new Azure Virtual Desktop session hosts. Please follow the steps below to use your image and deploy it to one of your hostpools.

Deploy Host

In my WVD (AVD) Github Repo there is a script available which lets you deploy a virtual machine (session host) from an existing Shared Image Gallery Image version. The relative path to the script is as follows: ImageManagement\New-VMfromSIG.ps1

Region: Variables

To keep the AVD host deployment flexible and interactive we have a lot of variables that need to be populated and inserted in the ARM templates. There are a couple of fixed variables that you would need to alter. A couple of out-gridviews should help you to select the other required variables/parameters.

#region variables

# General Variables

$ResourceGroup = Get-AzResourceGroup | Out-GridView -Title "Please select the resource group where you want to deploy your AVD host" -PassThru # Resource Group Selection

# Image Variables

$imagegallery = Get-AzGallery | Out-GridView -Title "Select the SIG you want to use" -PassThru # Gallery Selection
$imagegallerydefinitioninfo = Get-AzGalleryImageDefinition -GalleryName $imagegallery.Name -ResourceGroupName $imagegallery.ResourceGroupName | Out-GridView -Title "Select the SIG Definition" -PassThru # Image Definition Selection
$imagegalleryinfo = Get-AzGalleryImageVersion -GalleryName $imagegallery.Name -ResourceGroupName $imagegallery.ResourceGroupName -GalleryImageDefinitionName $imagegallerydefinitioninfo.Name | Out-GridView -Title "Select the SIG Image Version" -PassThru # Image Version Selection
$imagereference = @{"id" = $imagegalleryinfo.Id }

$vmprefix = read-host "Enter a virtual machine prefix" # Enter the virtual machine prefix (something like wvd, avd, avdmulti, avdsingle)
$vmsize = "Standard_D4s_v3" # Enter a virtual machine size
$vmcount = "1" # Enter the amount of virtual machine you like
$vmoffset = "0" # Enter the start number of your virtual machine
$vmstorage = "Premium_LRS" #Enter the storage type
$vmlicensetype = "Windows_Server"

# Identity variables

$domainname = Read-Host "Enter the domain where you want to join the host"
$localuser = "thebossman" # Enter a local username
$localuserpwd = "YvsVaP9#FLRDtdU8*KAE#237 "# Read-Host "Enter a local password" # Enter a local password
$domainadminuser = "" # Read-Host "Enter a domain admin user" # Enter a domain admin user
$domainadminpassword = "YvsVaP9#FLRDtdU8*KAE#237" # Read-Host "Enter a domain admin password" # Enter a domain admin password

# Virtual Network Variables

$VirtualNetwork = Get-AzVirtualnetwork | Out-GridView -Title "Select the virtual network where you want to deploy this virtual machine" -PassThru
$Subnet = Get-AzVirtualNetworkSubnetConfig -VirtualNetwork $VirtualNetwork  | Out-GridView -Title "Select the subnet" -PassThru
$subnetid = $subnet.Id

# Monitoring Variables

$omsworkspace = Get-AzOperationalInsightsWorkspace | Out-GridView -Title "Select the OMS workspace" -PassThru

# Azure Virtual Desktop variables

$hostpool = Get-AzWvdHostPool | Out-GridView -Title "Select the hostpool where you want to deploy your host" -PassThru
$hostpoolresource = Get-AzResource -ResourceId $hostpool.Id
$registrationinfo = Get-AzWvdRegistrationInfo -ResourceGroupName $hostpoolresource.ResourceGroupName -HostPoolName $hostpool.Name
If (!($registrationinfo.Token)) {
    $registrationinfo = New-AzWvdRegistrationInfo -ResourceGroupName $hostpoolresource.ResourceGroupName -HostPoolName $hostpool.Name -ExpirationTime $((get-date).ToUniversalTime().AddDays(1).ToString('yyyy-MM-ddTHH:mm:ss.fffffffZ'))


Region: ARM Deployment

Now that we have our variables stored, we can populate our ARM Template parameters with these variables.
Before deploying the template, be sure to check the content of the “$parameterobject” variable

! Note : the assumption is that you already have a workspace, hostpool & application group available

#region ARM deployment

Write-host "Retrieving ARM template and template parameter files"
$armfile = Join-Path -Path "." -ChildPath 'imagemanagement' -AdditionalChildPath "arm","avd","host", "azuredeploy.json" | Get-Item
$armparamfile = Join-Path -Path "." -ChildPath 'imagemanagement' -AdditionalChildPath "arm","avd","host", "azuredeploy.parameters.json" | Get-Item

Write-host "Setting arm template parameter file variables"
$armparamobject = Get-Content $armparamfile.FullName | ConvertFrom-Json -AsHashtable


$armparamobject.parameters.LocalAdminUser.value = $localuser
$armparamobject.parameters.Registrationtoken.value = $registrationinfo.Token
$armparamobject.parameters.LocalAdminPassword.value = $localuserpwd
$armparamobject.parameters.omsworkspaceresourceid.value = $omsworkspace.ResourceId
$armparamobject.parameters.WVDCount.value = [int]$vmcount
$armparamobject.parameters.WVDOU.value = ""
$armparamobject.parameters.SubnetId.value = $subnetid
$armparamobject.parameters.WVDVMOffset.value = [int]$vmoffset
$armparamobject.parameters.WVDSeries.value = $vmsize
$armparamobject.parameters.galleryImageVersionName.value = $imagegalleryinfo.Name
$armparamobject.parameters.galleryName.value = $imagegallery.Name
$armparamobject.parameters.Domainname.value = $domainname
$armparamobject.parameters.WVDPrefix.value = $vmprefix
$armparamobject.parameters.WVDVMStorageType.value = $vmstorage
$armparamobject.parameters.DomainAdminUpn.value = $domainadminuser
$armparamobject.parameters.galleryImageDefinitionName.value = $imagegallerydefinitioninfo.Name
$armparamobject.parameters.DomainAdminPassword.value = $domainadminpassword
$armparamobject.parameters.hostpoolName.value = $hostpool.Name

$parameterobject = @{ }
$armparamobject.parameters.keys | ForEach-Object { $parameterobject[$_] = $armparamobject.parameters[$_]['value'] }

Write-host "Deployment SPOKEvm-${vmprefix}-${vmoffset}-${vmcount} started "
$Deploy_SPOKEvm = New-AzResourceGroupDeployment -ResourceGroupName $ResourceGroup -Name "SPOKEvm-${vmprefix}-${vmoffset}-${vmcount}" -TemplateFile $armfile.FullName -TemplateParameterObject $parameterobject
Write-host "Deployment SPOKEvm-${vmprefix}-${vmoffset}-${vmcount} complete "


Note: Yes, I am still mixing up WVD and AVD in my ARM templates and script (work in progress)

After you have deployed the final section you can find your new host available in the host pool.

Host pool – Session Hosts
Virtual Machine overview
Time to connect

After a successful authentication I am now logged on to my shiny new Windows Server 2022 Azure Virtual Desktop host.

Thank you!

Thank you for reading through this blog post. I hope I have been able to assist you in creating your first Windows Server 2022 Azure Virtual Desktop host with Azure Image Builder.

If you encounter any new insights, feel free to drop me a comment or contact me via mail or other social media channels

2 Comments on “How to create a Windows Server 2022 (AVD) image with Azure Image Builder and deploy it to Azure Virtual Desktop

Leave a Reply

Your email address will not be published. Required fields are marked *

Please reload

Please Wait