Azure Local – IaC – AVD – Azure DevOps – Deploy session host
- Template files
- deploy vm – bicep template with parameter file
- BICEP to deploy extension to session host:
- EnrollToAVD.ps1:
- Parameter file to enrollment
- Deployment files
An essential part of building AVD on Azure Local, is to deploy session hosts. In this article I want to share the template I normally use for this task. It is written in Bicep.
Template files
deploy vm – bicep template with parameter file
@description('A username in the domain that has privileges to join the session hosts to the domain. For example, \'vmjoiner@contoso.com\'.')
param administratorAccountUsername string
@description('The password that corresponds to the existing domain username.')
@secure()
param administratorAccountPassword string
@description('A username to be used as the virtual machine administrator account. The vmAdministratorAccountUsername and vmAdministratorAccountPassword parameters must both be provided. Otherwise, domain administrator credentials provided by administratorAccountUsername and administratorAccountPassword will be used.')
param vmAdministratorAccountUsername string
@description('The password associated with the virtual machine administrator account. The vmAdministratorAccountUsername and vmAdministratorAccountPassword parameters must both be provided. Otherwise, domain administrator credentials provided by administratorAccountUsername and administratorAccountPassword will be used.')
@secure()
param vmAdministratorAccountPassword string
@description('VM Name')
param SessionHostName string
@description('OUPath for the domain join')
param ouPath string
@description('Domain to join')
param domain string
@description('A deployment target created and customized by your organization for creating virtual machines. The custom location is associated to an Azure Stack HCI cluster. E.g., /subscriptions//resourcegroups/Contoso-rg/providers/microsoft.extendedlocation/customlocations/Contoso-CL.')
param customLocationId string
@description('Virtual Processor Count. Default is 4.')
param virtualProcessorCount int
@description('Memory in GB. Default is 8.')
param memoryMB int
@description('Secure boot, TPM and trusted launch settings')
param hciSecurityType string
param hciTPM bool
param hciSecureBoot bool
@description('Region settings')
param vmLocation string
var location = (vmLocation)
@description('Id of Azure Local Network')
param hciLogicalNetworkId string
@description('Image ID of Azure Local Gallery Image')
param hciImageId string
@description('Custom Script File Path for AVD Session Host Languagepack installation')
param CustomExtensionFileURISetDKLanguage string
var ADdomain = ((domain == '') ? last(split(administratorAccountUsername, '@')) : domain)
var isVMAdminAccountCredentialsProvided = ((vmAdministratorAccountUsername != '') && (vmAdministratorAccountPassword != ''))
var vmAdministratorUsername = (isVMAdminAccountCredentialsProvided ? vmAdministratorAccountUsername : first(split(vmAdministratorAccountUsername, '@')))
var vmAdministratorPassword = (isVMAdminAccountCredentialsProvided ? vmAdministratorAccountPassword : vmAdministratorAccountPassword)
var securityProfile = {
uefiSettings: {
secureBootEnabled: hciSecureBoot
}
securityType: hciSecurityType
enableTPM: hciTPM
}
resource avdshPrefix_SessionHostName_nic 'Microsoft.AzureStackHCI/networkinterfaces@2023-09-01-preview' = {
name: '${SessionHostName}-nic'
location: location
extendedLocation: {
type: 'CustomLocation'
name: customLocationId
}
properties: {
ipConfigurations: [
{
name: '${SessionHostName}-nic'
properties: {
subnet: {
id: hciLogicalNetworkId
}
}
}
]
}
}
resource avdshPrefix_SessionHostName 'Microsoft.HybridCompute/machines@2023-06-20-preview' = {
name: SessionHostName
location: location
kind: 'HCI'
identity: {
type: 'SystemAssigned'
}
}
resource default 'microsoft.azurestackhci/virtualmachineinstances@2023-09-01-preview' = {
scope: avdshPrefix_SessionHostName
name: 'default'
properties: {
hardwareProfile: {
vmSize: 'Custom'
processors: virtualProcessorCount
memoryMB: memoryMB
}
osProfile: {
adminUsername: vmAdministratorUsername
adminPassword: vmAdministratorPassword
windowsConfiguration: {
timeZone: 'Romance Standard Time'
provisionVMAgent: true
provisionVMConfigAgent: true
}
computerName: SessionHostName
}
storageProfile: {
imageReference: {
id: hciImageId
}
}
networkProfile: {
networkInterfaces: [
{
id: resourceId('Microsoft.AzureStackHCI/networkinterfaces', '${SessionHostName}-nic')
}
]
}
securityProfile: (((hciSecurityType == 'TrustedLaunch') || (hciSecurityType == 'ConfidentialVM')) ? securityProfile : null)
}
extendedLocation: {
type: 'CustomLocation'
name: customLocationId
}
dependsOn: [
avdshPrefix_SessionHostName_nic
]
}
resource avdshPrefix_SessionHostName_CustomScriptExtension_LanguagePack 'Microsoft.HybridCompute/machines/extensions@2023-03-15-preview' = {
parent: avdshPrefix_SessionHostName
name: 'CustomScriptExtension'
location: location
properties: {
publisher: 'Microsoft.Compute'
type: 'CustomScriptExtension'
autoUpgradeMinorVersion: true
settings: {
fileUris: [
CustomExtensionFileURISetDKLanguage
]
}
protectedSettings: {
commandToExecute: 'powershell -ExecutionPolicy Unrestricted -File Set_DK_Language.ps1'
}
}
dependsOn: [
default
]
}
resource avdshPrefix_SessionHostName_joindomain 'Microsoft.HybridCompute/machines/extensions@2023-03-15-preview' = {
parent: avdshPrefix_SessionHostName
name: 'joindomain'
location: location
properties: {
publisher: 'Microsoft.Compute'
type: 'JsonADDomainExtension'
typeHandlerVersion: '1.3'
autoUpgradeMinorVersion: true
settings: {
name: ADdomain
oUPath: ouPath
user: administratorAccountUsername
restart: 'true'
options: '3'
}
protectedSettings: {
password: administratorAccountPassword
}
}
dependsOn: [
avdshPrefix_SessionHostName_CustomScriptExtension_LanguagePack
]
}
The template above should be used with either a Bicep parameter file, JSON parameter file or parsed as inputs using Azure DevOps variable group.
I use a .json file in this example:
{
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"virtualProcessorCount": {
"value": 8
},
"memoryMB": {
"value": 102400
},
"hciImageId": {
"value": "/subscriptions/SUBIDHERE/resourceGroups/RGHERE/providers/Microsoft.AzureStackHCI/galleryImages/Win11-Prod-Image"
},
"hciLogicalNetworkId": {
"value": "/subscriptions/SUBIDHERE/resourceGroups/RGHERE/providers/microsoft.azurestackhci/logicalnetworks/VLANNAME"
},
"hciTPM": {
"value": false
},
"hciSecureBoot": {
"value": true
},
"hciSecurityType": {
"value": "Standard"
},
"vmLocation": {
"value": "westeurope"
},
"administratorAccountUsername": {
"value": "joindomainavd@domain.dk"
},
"domain": {
"value": "domain.dk"
},
"ouPath": {
"value": "OU=AVD,OU=Servers,DC=domain,DC=dk"
},
"vmAdministratorAccountUsername": {
"value": "localadmin"
},
"CustomExtensionFileURISetDKLanguage": {
"value": "https://stname.blob.core.windows.net/iacavd/Set_DK_Language.ps1"
}
}
}
But above will actually only deploy the VM to Azure Local, install a language pack (script not provided here), and join to domain. But we still need to add the session host to a host pool in AVD. For this task I usually run another task in my DevOps pipeline, that uses custom script extension to run a script on the session host, that will join it to a host pool.
BICEP to deploy extension to session host:
@description('Custom Script File Path for AVD Session Host Pool Registration')
param CustomExtensionFileURI string
@description('Token for AVD Session Host Pool Registration')
param AVDHostPoolRegistrationToken string
@description('Region settings')
param vmLocation string
var location = (vmLocation)
@description('VM Name')
param SessionHostName string
resource avdshPrefix_SessionHostName 'Microsoft.HybridCompute/machines@2023-06-20-preview' = {
name: SessionHostName
location: location
kind: 'HCI'
identity: {
type: 'SystemAssigned'
}
}
resource avdshPrefix_vmInitialNumber_CustomScriptExtension 'Microsoft.HybridCompute/machines/extensions@2023-03-15-preview' = {
parent: avdshPrefix_SessionHostName
name: 'CustomScriptExtension'
location: location
properties: {
publisher: 'Microsoft.Compute'
type: 'CustomScriptExtension'
autoUpgradeMinorVersion: true
settings: {
fileUris: [
CustomExtensionFileURI
]
}
protectedSettings: {
commandToExecute: 'powershell -ExecutionPolicy Unrestricted -File EnrollToAVD.ps1 -RdsRegistrationInfotoken ${AVDHostPoolRegistrationToken}'
}
}
dependsOn: [
]
}
EnrollToAVD.ps1:
EnrollToAVD.ps1 file must be saved and uploaded to a storage account in Azure, that the session hosts can reach.
param(
[Parameter(Mandatory)]
[string]$RdsRegistrationInfotoken
)
#Set Variables
$RootFolder = "C:\Packages\Plugins\"
$WVDAgentInstaller = $RootFolder+"WVD-Agent.msi"
$WVDBootLoaderInstaller = $RootFolder+"WVD-BootLoader.msi"
#$WVDnetframworkInstaller = $RootFolder+"net48runtime.exe"
if (!(Test-Path -Path $RootFolder)){New-Item -Path $RootFolder -ItemType Directory}
Write-Host "##[debug]Download WVD Agent & bootloader,.netframwork"
$files = @(
@{url = "https://go.microsoft.com/fwlink/?linkid=2310011"; path = $WVDAgentInstaller}
@{url = "https://query.prod.cms.rt.microsoft.com/cms/api/am/binary/RWrxrH"; path = $WVDBootLoaderInstaller}
# @{url = "https://go.microsoft.com/fwlink/?linkid=2088631"; path = $WVDnetframworkInstaller}
)
$workers = foreach ($f in $files)
{
$wc = New-Object System.Net.WebClient
Write-Output $wc.DownloadFileTaskAsync($f.url, $f.path)
}
$workers.Result
#Install the .net
#Start-Process "$WVDnetframworkInstaller" -argumentlist "/q /norestart" -wait
#Unistall the WVD Agent
#Write-Host "##[debug]Uninstall the AVD Agent"
#Start-Process -FilePath "msiexec.exe" -ArgumentList "/X $WVDAgentInstaller", "/quiet", "/qn", "/norestart", "/passive", "/l* C:\Users\AgentInstall.txt" -wait
#Uninstall the WVD Bootloader
#Write-Host "##[debug]Install the Boot Loader"
#Start-Process -FilePath "msiexec.exe" -ArgumentList "/X $WVDBootLoaderInstaller", "/quiet", "/qn", "/norestart", "/passive", "/l* C:\Users\AgentBootLoaderInstall.txt" -wait
#Install the WVD Agent
Write-Host "##[debug]Install the AVD Agent"
Start-Process -FilePath "msiexec.exe" -ArgumentList "/i $WVDAgentInstaller", "/quiet", "/qn", "/norestart", "/passive", "REGISTRATIONTOKEN=$RdsRegistrationInfotoken", "/l* C:\Packages\Plugins\AgentInstall.txt" -wait
#Install the WVD Bootloader
Write-Host "##[debug]Install the Boot Loader"
Start-Process -FilePath "msiexec.exe" -ArgumentList "/i $WVDBootLoaderInstaller", "/quiet", "/qn", "/norestart", "/passive", "/l* C:\Packages\Plugins\AgentBootLoaderInstall.txt" -wait
Parameter file to enrollment
Below is a parameter file in JSON format that I use in my final script to save parameters for the deployment and enrollment of the extension that joins the VM to a host pool. The file should be called EnrollAVDSessionHost_AVDHostPool_parameters.json.
{
"$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
"contentVersion": "1.0.0.0",
"parameters": {
"CustomExtensionFileURI": {
"value": "https://stname.blob.core.windows.net/iacavd/EnrollToAVD.ps1"
},
"vmLocation": {
"value": "westeurope"
}
}
}
Deployment files
Below is the complete PowerShell file I use in my pipeline deployment. I define this PowerShell file as my pipeline task file. Please read through the file to locate all the parameters that must be parsed into the task job to make a successful deployment.
<#
.NOTES
===========================================================================
Version: 1.0.0
Updated on: 26-08-2025
Created by: Christoffer Jakobsen - chkja.dk
===========================================================================
PowerShell 7 is required
Microsoft Azure Accounts Module is required
Install-Module -Name Az.Accounts
https://www.powershellgallery.com/packages/Az.Accounts/
Microsoft Azure KeyVaults Module is required
Install-Module -Name Az.KeyVault
https://www.powershellgallery.com/packages/Az.KeyVault/
Microsoft Azure Desktop Virtulization Module is required
Install-Module -Name Az.DesktopVirtualization
https://www.powershellgallery.com/packages/ Az.DesktopVirtualization/
.DESCRIPTION
Deploys a virtual machine into custom location in Azure Local and enrolls it to an AVD Host Pool.
Script is used in Azure DevOps pipeline.
#>
#########################################
# #
# VARIABLES #
# #
#########################################
$DevOps = $env:DevOps
# PATHS
# Define working directory - needed for DevOps mode
if($DevOps -eq "true" -or $DevOps -eq "True")
{
$DefaultWorkingDirectory = $env:System_DefaultWorkingDirectory+"\DEV\DeployAVDSessionHost"
write-host "Default Working Directory is:" $DefaultWorkingDirectory
write-host "DevOps Mode:" $DevOps
}
if($DevOps -ne "true" -or $DevOps -ne "True")
{
$DefaultWorkingDirectory = "."
$DevOps = "False"
write-host "DevOps Mode:" $DevOps
}
$Date = Get-Date -Format "yyyy-MM-dd"
$SubscriptionID = $env:SubscriptionID # Azure Subscription ID
$RG = $env:RG # Resource Group Name where the session host will be deployed and custom location exists
$HostPoolName = $env:HostPoolName
$SessionHostName = $env:SessionHostName # Name of session host to be deployed
$CustomLocation = $env:CustomLocation # Name of Azure Arc enabled server custom location - Azure Local
$ARMNameMain = "AVDSH-Deploy-"+$SessionHostName+"-"+$Date # Name of the deployment in ARM
$ARMNameExtension = "AVDSH-Deploy-"+$SessionHostName+"-AVDSH-Register-"+$Date # Name of the deployment in ARM
$CustomLocationId = "/subscriptions/" + $SubscriptionID + "/resourceGroups/" + $RG + "/providers/microsoft.extendedlocation/customlocations/" + $CustomLocation # Azure Arc enabled server custom location ID
$administratorAccountPassword = $env:administratorAccountPassword
$administratorAccountPassword = ConvertTo-SecureString $administratorAccountPassword -asplaintext -force
$vmAdministratorAccountPassword = $env:vmAdministratorAccountPassword
$vmAdministratorAccountPassword = ConvertTo-SecureString $vmAdministratorAccountPassword -asplaintext -force
#Get Azure Subscription Context.
$context = get-azcontext
$subId = $context.Subscription.Id
write-host "Azure subscription is: "$context.Subscription
#########################################
# #
# Pre-Reqs #
# #
#########################################
#Get AVD Registration Info token
Write-Host "Obtain AVD Registration Info token"
$Registered = Get-AzWvdRegistrationInfo -SubscriptionId $subId -ResourceGroupName $RG -HostPoolName $HostPoolName
if (-not(-Not $Registered.Token)){$registrationTokenValidFor = (NEW-TIMESPAN -Start (get-date) -End $Registered.ExpirationTime | select-object Days,Hours,Minutes,Seconds)}
Write-Host "##[debug]Token is valid for:$registrationTokenValidFor"
if ((-Not $Registered.Token) -or ($Registered.ExpirationTime -le (get-date)))
{
$Registered = New-AzWvdRegistrationInfo -SubscriptionId $subId -ResourceGroupName $RG -HostPoolName $HostPoolName -ExpirationTime (Get-Date).AddHours(4) -ErrorAction SilentlyContinue
}
$AVDHostPoolRegistrationToken = $Registered.Token
#########################################
# #
# Deployment #
# #
#########################################
# Deploy session host to Azure Local and enroll to AVD Host Pool
# Deploy Session Host and join to domain
New-AzResourceGroupDeployment -verbose -Name $ARMNameMain `
-ResourceGroupName $RG `
-TemplateFile "$DefaultWorkingDirectory\DeployAVDSessionHost_AzureLocal.bicep" `
-TemplateParameterFile "$DefaultWorkingDirectory\DeployAVDSessionHost_AzureLocal_parameters.json" `
-administratorAccountPassword $administratorAccountPassword `
-vmAdministratorAccountPassword $vmAdministratorAccountPassword `
-SessionHostName $SessionHostName `
-CustomLocationId $CustomLocationId `
-DeploymentDebugLogLevel All
# Sleep for 2 minutes - allows VM to reboot completely before trying to enroll to AVD Host Pool
Start-Sleep -s 120
# Enroll session host to AVD Host Pool
New-AzResourceGroupDeployment -verbose -Name $ARMNameExtension `
-ResourceGroupName $RG `
-TemplateFile "$DefaultWorkingDirectory\EnrollAVDSessionHost_AVDHostPool.bicep" `
-TemplateParameterFile "$DefaultWorkingDirectory\EnrollAVDSessionHost_AVDHostPool_parameters.json" `
-AVDHostPoolRegistrationToken $AVDHostPoolRegistrationToken `
-SessionHostName $SessionHostName `
-DeploymentDebugLogLevel All
