Starting with Bicep templates for Azure Virtual Desktop

Hello everyone!

Deploying Azure Virtual Desktop is possible in endless ways, but Bicep templates combined with Azure DevOps could give you a solid backbone for your Azure infrastructure. This blog will show you the possibilities with Bicep templates and Azure Virtual Desktop. Deploying the backend and session hosts with some extra features. This post will explain the Bicep templates and setup.

Prerequisites

  • Azure subscription (Azure DevOps is free, but I will connect to Azure)
  • Contributor or Owner role on the subscription (less rights are possible, but take more time to setup)
  • Visual Studio Code (VSCode) (Feel free to use any coding tool you want)
  • Bicep extension in VSCode

What is in the deployment?

We are going to deploy a Azure Virtual Desktop environment with a pooled/multi-user full desktop. The environment needs domain connectivity. This can be deployed with these Bicep templates. There are also some extra resources created for the hostpool, like a storage account and managed identity to automate some of the tasks.

Explaining the template files

This part will explain every Bicep from my complete deployment of Azure Virutal Desktop. I will always try to make the templates as easy as possible, so you can start out with Bicep templates yourself. VSCode is really helping out great with the Bicep extension.

All my files are visible and available for download at my GitHub. So check it outmoving2cloud/BiCepAVD at main · moving2cloud/moving2cloud (github.com)

First we start withe some of the general resources that are needed for this deployment.

Backend Bicep templates for AVD

network security group is needed to protect and configure for the virtual network that we are going to create for AVD. The firewall rules here are an example what is possible. If you need domain connectivity, the settings could be different. All files need to be saved as a bicep file.

@description('Name of the NSG')
param name string

@description('Tags on the NSG')
param tags object

@description('Region of NSG')
param location string = resourceGroup().location

resource networksecuritgroup 'Microsoft.Network/networkSecurityGroups@2023-11-01' = {
  name: 'nsg-${name}'
  location: location
  tags: tags
  properties: {
    securityRules: [
      {
        name: 'Inbound App01'
        properties: {
          protocol: 'Tcp'
          sourcePortRange: '*'
          destinationPortRanges:  [
            '8080'
            '5701'
          ]
          sourceAddressPrefix: '*'
          destinationAddressPrefix: '10.0.59.10/24'
          access: 'Allow'
          priority: 120
          direction: 'Inbound'
        }
      }
    ]
  }
}

output id string = networksecuritgroup.id

Now we are going to create the virtual network for AVD. Here you have some options available that need to be set for your own environment. My deployment needs domain connectivity so be aware that this VNET must have access to the domain controllers.

@description('Azure region of the deployment')
param location string = resourceGroup().location

@description('Tags to add to the resources')
param tags object

@description('Name of the virtual network resource')
param name string

@description('Group ID of the network security group')
param networkSecurityGroupId string

@description('Virtual network address prefix')
param vnetAddressPrefix string 

@description('subnet address prefix')
param SubnetPrefix string 

@description('DNS Settings for the VNET')
param dnsServer string

resource virtualnetwork 'Microsoft.Network/virtualNetworks@2023-09-01' = {
  name: 'vn-${name}'
  location: location
  tags: tags
  properties: {
    addressSpace: {
      addressPrefixes: [
        vnetAddressPrefix
      ]
    }
    dhcpOptions: (!empty(dnsServer) ? {
      dnsServers: [
        dnsServer
      ]
    } : json('null'))
    subnets: [
      { 
        name: 'sn-${name}'
        properties: {
          addressPrefix: SubnetPrefix
          privateEndpointNetworkPolicies: 'Disabled'
          privateLinkServiceNetworkPolicies: 'Disabled'
          networkSecurityGroup: {
            id: networkSecurityGroupId
          }
        }
      }
    ]
  }
}

output id string = virtualnetwork.id
output name string = virtualnetwork.name

We also want to create monitoring for AVD, so I have a template that will configure everything. So the parameter template will not be needed for this configuration. Feel free to change some of the values.

@description('Location of the Log Analytics Workspace')
param location string = resourceGroup().location

@description('Mame of log Analytics Workspace')
param logAnalyticsWorkspaceName string = 'la-${uniqueString(resourceGroup().id)}'

var vmInsights = {
  name: 'VMInsights(${logAnalyticsWorkspaceName})'
  galleryName: 'VMInsights'
}

var environmentName = 'Production'
var costCenterName = 'AVD'

resource logAnalyticsWorkspace 'Microsoft.OperationalInsights/workspaces@2023-09-01' = {
  name: logAnalyticsWorkspaceName
  location: location
  tags: {
    Environment: environmentName
    CostCenter: costCenterName
  }
  properties: any({
    retentionInDays: 90
    features: {
      searchVersion: 1
    }
    sku: {
      name: 'PerGB2018'
    }
  })
}


resource solutionsVMInsights 'Microsoft.OperationsManagement/solutions@2015-11-01-preview' = {
  name: vmInsights.name
  location: location
  properties: {
    workspaceResourceId: logAnalyticsWorkspace.id
  }
  plan: {
    name: vmInsights.name
    publisher: 'Microsoft'
    product: 'OMSGallery/${vmInsights.galleryName}'
    promotionCode: ''
  }
}

When you need to delegate some tasks to automate you will need an identity account to perform those tasks. Here I will choose an User Managed Identity. I will use this account to install the Azure Monitoring Agent on the session hosts.


@description('The name of the managed identity resource.')
param managedIdentityName string

@description('The IDs of the role definitions to assign to the managed identity. Each role assignment is created at the resource group scope. Role definition IDs are GUIDs. To find the GUID for built-in Azure role definitions, see https://docs.microsoft.com/azure/role-based-access-control/built-in-roles. You can also use IDs of custom role definitions.')
param roleDefinitionIds array

@description('An optional description to apply to each role assignment, such as the reason this managed identity needs to be granted the role.')
param roleAssignmentDescription string = ''

@description('The Azure location where the managed identity should be created.')
param location string = resourceGroup().location

var roleAssignmentsToCreate = [for roleDefinitionId in roleDefinitionIds: {
  name: guid(managedIdentity.id, resourceGroup().id, roleDefinitionId)
  roleDefinitionId: roleDefinitionId
}]

resource managedIdentity 'Microsoft.ManagedIdentity/userAssignedIdentities@2023-07-31-preview' = {
  name: managedIdentityName
  location: location
}

resource roleAssignment 'Microsoft.Authorization/roleAssignments@2022-04-01' = [for roleAssignmentToCreate in roleAssignmentsToCreate: {
  name: roleAssignmentToCreate.name
  scope: resourceGroup()
  properties: {
    description: roleAssignmentDescription
    principalId: managedIdentity.properties.principalId
    roleDefinitionId: subscriptionResourceId('Microsoft.Authorization/roleDefinitions', roleAssignmentToCreate.roleDefinitionId)
    principalType: 'ServicePrincipal' // See https://docs.microsoft.com/azure/role-based-access-control/role-assignments-template#new-service-principal to understand why this property is included.
  }
}]

@description('The resource ID of the user-assigned managed identity.')
output managedIdentityResourceId string = managedIdentity.id

@description('The ID of the Azure AD application associated with the managed identity.')
output managedIdentityClientId string = managedIdentity.properties.clientId

@description('The ID of the Azure AD service principal associated with the managed identity.')
output managedIdentityPrincipalId string = managedIdentity.properties.principalId

When working with a pooled environment, you will need a storage account for the FSLogix profiles. I will also create this in the same backend creation step with a Bicep template.

@description('Specifies the name of the Azure Storage account.')
param storageAccountName string = 'fslogix${uniqueString(resourceGroup().id)}'

@description('Specifies the name of the File Share. File share names must be between 3 and 63 characters in length and use numbers, lower-case letters and dash (-) only.')
@minLength(3)
@maxLength(63)
param fileShareName string

@description('Specifies the location in which the Azure Storage resources should be deployed.')
param location string = resourceGroup().location

@description('Sizing of the fileshare in GB')
param shareQuota int

@description('Tags on the storage account')
param tags object

resource safslogix 'Microsoft.Storage/storageAccounts@2023-01-01' = {
  name: storageAccountName
  tags: tags
  location: location
  kind: 'FileStorage'
  sku: {
    name: 'Premium_LRS'
  }
}

resource fileShare 'Microsoft.Storage/storageAccounts/fileServices/shares@2023-01-01' = {
  name: '${safslogix.name}/default/${fileShareName}'
   properties: {
    enabledProtocols: 'SMB'
    shareQuota: shareQuota
   }
}

output storageId string = safslogix.id

Now it is also possible to create a VNET peering for domain connectivity or maybe the access for some of the legacy apps. This step is optional for my deployment, but line of sight with the domain controller is necessary for this deployment. So the VNET or VNET peering must arrange that.

@description('Resource Groupname of exiwting VNET for the peering')
param vnetResourceGroupName string

@description('Existing VNET Name for the VNET peering')
param vnetName string

@description('Remote Subscription ID for the VNET peering')
param remoteVnetSubscriptionId string

@description('Remote resource group name for the VNET peering')
param remoteVnetRsourceGroupName string

@description('Remote VNET Name from an existing VNET')
param remoteVnetName string

resource remoteVirtualNetwork 'Microsoft.Network/virtualNetworks@2023-09-01' existing = {
  name: remoteVnetName
  scope: resourceGroup(remoteVnetSubscriptionId, remoteVnetRsourceGroupName)
}

resource virtualNetwork 'Microsoft.Network/virtualNetworks@2023-09-01' existing = {
  name: vnetName
  scope: resourceGroup(vnetResourceGroupName)
}

resource virtualNetworkPeering 'Microsoft.Network/virtualNetworks/virtualNetworkPeerings@2023-09-01' = {
  name: '${remoteVnetName}/${remoteVnetName}_to_${virtualNetwork.name}'
  properties: {
    allowVirtualNetworkAccess: true
    allowForwardedTraffic: true
    allowGatewayTransit: false
    useRemoteGateways: false
    remoteVirtualNetwork: {
      id: virtualNetwork.id
    }
  }

  dependsOn: [
    remoteVirtualNetwork
  ]
}

Now we start with AVD backplane and create the hostpool. This Bicep template could have more options, but I will choose the most important ones.

@description('Name of AVD Hostpool')
param name string

@description('Tags on the AVD Hostpool')
param tags object

@description('Region of AVD Hostpool')
param location string = resourceGroup().location

@description('Type of AVD Hostpool')
@allowed([
  'Personal'
  'Pooled'
])
param hostPoolType string

@description('Type of load balancing')
@allowed([
  'BreadthFirst'
  'DepthFirst'
])
param loadBalancerType string

@description('Maximum users for the session hosts')
param maxSessionLimit int

@description('Type of Application Group')
@allowed([
  'Desktop'
  'RailApplictions'
  'None'
])
param preferredAppGroupType string

@description('Sets the base time value in specified format')
param baseTime string = utcNow('u')

var expirationTime = dateTimeAdd(baseTime, 'PT24H')

resource hostpool 'Microsoft.DesktopVirtualization/hostPools@2024-01-16-preview' = {
  name: 'hp-${name}'
  location: location
  tags: tags
  properties: {
    hostPoolType: hostPoolType
    loadBalancerType: loadBalancerType
    preferredAppGroupType: preferredAppGroupType
    maxSessionLimit: maxSessionLimit
    startVMOnConnect: true
    validationEnvironment: false
    customRdpProperty: 'redirectprinters:i:0;redirectsmartcards:i:1;enablecredsspsupport:i:1;use multimon:i:1;autoreconnection enabled:i:1;dynamic resolution:i:1;smart sizing:i:1;audiocapturemode:i:1;encode redirected video capture:i:1;camerastoredirect:s:*;redirected video capture encoding quality:i:0;audiomode:i:0'
    registrationInfo: {
      expirationTime: expirationTime
      token: null
      registrationTokenOperation: 'Update'
    }

  }

}


output id string = hostpool.id

The next step is the application group, this is needed for the Desktop app and access for the hostpool.

@description( 'Name of the application group')
param name string

@description('Friendly Name of Application Group')
param friendlyNameApg string

@description('Tags for the Application Groups')
param tags object

@description('Region the Application Group')
param location string = resourceGroup().location

@description('Hostpool ID: Needed for applying to the right hostpool')
param hostPoolId string

resource applicationgroup 'Microsoft.DesktopVirtualization/applicationGroups@2024-01-16-preview' = {
  name: 'apg-${name}'
  location: location
  tags: tags
  properties: {
    applicationGroupType: 'Desktop'
    hostPoolArmPath: hostPoolId
    friendlyName: friendlyNameApg
  }
}

output id string = applicationgroup.id

The workspace is also important, because users can have access to multiple workspaces or multiple application groups that are connected to the workspace.

@description('Name of AVD Workspace')
param name string

@description('Location of the AVD workspace')
param location string = resourceGroup().location

@description('Description of the AVD workspace')
param descriptionws string

@description('Friend Name of the AVD workspace')
param friendlyNameWs string

@description('Application Group assignment of the AVD workspace')
param applicationgroupid string

@description('Tags on the workspace of AVD')
param tags object

resource workspace 'Microsoft.DesktopVirtualization/workspaces@2024-01-16-preview' = {
  name: 'ws-${name}'
  location: location
  tags: tags
  properties: {
    description: descriptionws
    friendlyName: friendlyNameWs
    applicationGroupReferences: [
      applicationgroupid
    ]

  }
}

We are also want to create the scalingplan for this deployment. Because saving costs is important and creating this within the deployment will save you some costs.

@description('Name of scalingplan')
param name string

@description('Region of scalingplan')
param location string = resourceGroup().location

@description('Tags of the scalingplan')
param tags object

@description('Exclusion Tag for exclusing session hosts from the scalingplan')
param exclusionTag string

@description('Friendly name of the scalingplan')
param friendlyNameSC string

@description('Hostpool ID: Needed for applying to the right hostpool')
param hostPoolId string

@description('Scaling plan Hostpool type')
param hostPoolType string

@description('Name of scedule')
param nameSchedule string

@description('ScalingPlan Enabled or not')
param scalingPlanEnabled bool

@description('Rampdown Notification for users to log off')
param rampDownNotificationMessage string


resource scalingplan 'Microsoft.DesktopVirtualization/scalingPlans@2024-01-16-preview' = {
  name: 'scpl-${name}'
  location: location
  tags: tags
  properties: {
    exclusionTag: exclusionTag
    friendlyName: friendlyNameSC
    hostPoolReferences: [
      {
        hostPoolArmPath: hostPoolId
        scalingPlanEnabled: scalingPlanEnabled
      }
    ]
    hostPoolType: hostPoolType
    schedules: [
      {
        daysOfWeek: [
          'Monday'
          'Tuesday'
          'Wednesday'
          'Thursday'
          'Friday'
        ]
        name: nameSchedule
        offPeakLoadBalancingAlgorithm: 'DepthFirst'
        offPeakStartTime: {
          hour: 19
          minute: 0
        }
        peakLoadBalancingAlgorithm: 'BreadthFirst'
        peakStartTime: {
          hour: 8
          minute: 0
        }
        rampDownCapacityThresholdPct: 50
        rampDownForceLogoffUsers: true
        rampDownLoadBalancingAlgorithm: 'DepthFirst'
        rampDownMinimumHostsPct: 50
        rampDownNotificationMessage: rampDownNotificationMessage
        rampDownStartTime: {
          hour: 18
          minute: 0
        }
        rampDownStopHostsWhen: 'ZeroSessions'
        rampDownWaitTimeMinutes: 30
        rampUpCapacityThresholdPct: 80
        rampUpLoadBalancingAlgorithm: 'BreadthFirst'
        rampUpMinimumHostsPct: 10
        rampUpStartTime: {
          hour: 7
          minute: 0        
        }
      }
    ]
    timeZone: 'W. Europe Standard Time'
  }

}

Then we create the main bicep files. I will have one main for the backend and one for the session hosts. We start with the backend main file. Here you will call all the template files to connect them and make them applicable for the parameter file.

// General parameters for multiple resources

@description('Naming the resource in the deployment')
param name string

@description('Azure region of the deployment')
param location string = resourceGroup().location

@description('Tags to add to the resources')
param tags object

@description('Managed Identity Name')
param managedIdentityName string

@description('Type of AVD Hostpool')
@allowed([
  'Personal'
  'Pooled'
])
param hostPoolType string

//Parameter for deploying resources
@description('Parameter for the deploymentname time window, this will be visible in the Azure portal on the resource group')
param time string = replace(utcNow(), ':', '-')

// VNET parameters

@description('Virtual network address prefix')
param vnetAddressPrefix string

@description('subnet address prefix')
param SubnetPrefix string

@description('DNS Settings for the VNET')
param dnsServer string


// VNET Peering parameters

@description('Hub VNET name to connect the peering')
param hubVnetName string

@description('Hub resource group name for VNET peering')
param hubVnetRgName string

// Network Security Group parameters (information available in general parameters)


// Hostpool parameters

@description('Maximum users for the session hosts')
param maxSessionLimit int

// Applicationgroup parameters

@description('Friendly Name of Application Group')
param friendlyNameApg string

// Scalingplan parameters

@description('Exclusion Tag for exclusing session hosts from the scalingplan')
param exclusionTag string

@description('Friendly name of the scalingplan')
param friendlyNameSC string

@description('Name of scedule')
param nameSchedule string

@description('ScalingPlan Enabled or not')
param scalingPlanEnabled bool

@description('Rampdown Notification for users to log off')
param rampDownNotificationMessage string

// Workspace parameters

@description('Description of the AVD workspace')
param descriptionws string

@description('Friend Name of the AVD workspace')
param friendlyNameWs string

// Storage account parameters (FSLogix)

@description('Specifies the name of the Azure Storage account.')
param storageAccountName string = 'fslogix${uniqueString(resourceGroup().id)}'

@description('Specifies the name of the File Share. File share names must be between 3 and 63 characters in length and use numbers, lower-case letters and dash (-) only.')
@minLength(3)
@maxLength(63)
param fileShareName string

@description('Sizing of the fileshare in GB')
param shareQuota int

// Identity parameters for User Managed Assigned Identity

@description('The IDs of the role definitions to assign to the managed identity. Each role assignment is created at the resource group scope. Role definition IDs are GUIDs. To find the GUID for built-in Azure role definitions, see https://docs.microsoft.com/azure/role-based-access-control/built-in-roles. You can also use IDs of custom role definitions.')
param roleDefinitionIds array

// Monitoring parameters for Insights on AVD (All parameters are already configured in the bicep file)


module networksecuritygroup 'modules/networksecuritygroup.bicep' = {
  name: 'networksecuritygroup-${time}'
  params: {
    name: name
    tags: tags
    location: location
  }
}

module virtualnetwork 'modules/virtualnetwork.bicep' = {
  name: 'virtualnetwork-${time}'
  params: {
    name: name
    tags: tags
    location: location
    vnetAddressPrefix: vnetAddressPrefix
    SubnetPrefix: SubnetPrefix
    dnsServer: dnsServer
    networkSecurityGroupId: networksecuritygroup.outputs.id
  }
}

// Mainly needed when there is no domain connectivity in the created VNET
module vnetpeering01 'modules/vnetpeering.bicep' = {
  name: 'vnetpeering01-${time}'
  params: {
    remoteVnetName: virtualnetwork.outputs.name
    remoteVnetRsourceGroupName: resourceGroup().name
    remoteVnetSubscriptionId: subscription().id
    vnetName: hubVnetName
    vnetResourceGroupName: hubVnetRgName
  }
}

// Mainly needed when there is no domain connectivity in the created VNET
module vnetpeering02 'modules/vnetpeering.bicep' = {
  name: 'vnetpeering02-${time}'
  params: {
    remoteVnetName: hubVnetName
    remoteVnetRsourceGroupName: hubVnetRgName
    remoteVnetSubscriptionId: subscription().id
    vnetName: virtualnetwork.outputs.name
    vnetResourceGroupName: resourceGroup().name
  }
}

module hostpool 'modules/hostpool.bicep' = {
  name: 'hostpool-${time}'
  params: {
    name: name
    tags: tags
    location: location
    hostPoolType: 'Pooled'
    maxSessionLimit: maxSessionLimit
    loadBalancerType: 'BreadthFirst'
    preferredAppGroupType: 'Desktop'
  }
}

module applicationgroup 'modules/applicationgroup.bicep' = {
  name: 'applicationgroup-${time}'
  params: {
    name: name
    tags: tags
    location: location
    hostPoolId: hostpool.outputs.id
    friendlyNameApg: friendlyNameApg
  }
}

module scalingplan 'modules/scalingplan.bicep' = {
  name: 'scalingplan-${time}'
  params: {
    name: name
    tags: tags
    location: location
    hostPoolType: hostPoolType
    exclusionTag: exclusionTag
    friendlyNameSC: friendlyNameSC
    scalingPlanEnabled: scalingPlanEnabled
    nameSchedule: nameSchedule
    hostPoolId: hostpool.outputs.id
    rampDownNotificationMessage: rampDownNotificationMessage
  }
}

module workspace 'modules/workspace.bicep' = {
  name: 'workspace-${time}'
  params: {
    name: name
    tags: tags
    location: location
    applicationgroupid: applicationgroup.outputs.id
    descriptionws: descriptionws
    friendlyNameWs: friendlyNameWs
  }
}

module storageaccount 'modules/storageaccount.bicep' = {
  name: 'storageaccount-${time}'
  params: { 
    storageAccountName: storageAccountName
    fileShareName: fileShareName
    tags: tags
    location: location
    shareQuota: shareQuota
  }
}

module identity 'modules/identity.bicep' = {
  name: 'identity-${time}'
  params: {
    location: location
    managedIdentityName: managedIdentityName
    roleDefinitionIds: roleDefinitionIds
  }
}

module monitoring 'modules/monitoring.bicep' = {
  name: 'monitoring-${time}'
  params: {
    location:location
  }
}

The next part is the parameters file, this file will have some parameters that are needed to be adjusted to your needs. This parameter file is now filled with details of my deployment. Please save this file as a JSONC file.

{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {

    // General parameters used for multiple resources

    "name": {
        "value": "m2cavd"
    },

    "tags": {
        "value": {
            "M2C": "AVD",
            "Environment": "TEST"
        }
    },

    "managedIdentityName": {
        "value": "m2cmanid"
    },

    "hostPoolType": {
        "value": "Pooled"
    },


    // VNET parameters

    "vnetAddressPrefix": {
        "value": "10.5.0.0/16"
    },

    "SubnetPrefix": {
        "value": "10.5.0.0/24"
    },

    "dnsServer": {
        "value": "10.1.0.4"
    },

    // VNET Peering parameters (Optional)

    "hubVnetName": {
        "value": ""
    },

    "hubVnetRgName": {
        "value": ""
    },

    // Hostpool parameters

    "maxSessionLimit": {
        "value": 5
    },

    // Desktop Application Group parameters

    "friendlyNameApg": {
        "value": "M2C-DAPG"
    },

    // Scalingplan parameters

    "exclusionTag": {
        "value": "DoNotScale"
    },

    "friendlyNameSC": {
        "value": "Scalingplan for Pooled AVD - Test"
    },

    "nameSchedule": {
        "value": "weeklyschedule"
    },

    "scalingPlanEnabled": {
        "value": true
    },

    "rampDownNotificationMessage": {
        "value": "This session will be closed in about 5 minutes, please save all your work."
    },

    // Workspace parameters

    "descriptionws": {
        "value": "Workspace for M2C employees"
    },

    "friendlyNameWs": {
        "value": "M2C Workspace"
    },

    // Storage account parameters (Be aware that Premium storage is billed for the provisioned storage, not the used storage)

    "fileShareName": {
        "value": "fslogix"
    },

    "shareQuota": {
        "value": 100
    },

    // User Managed Identity parameters (The IDs of the roles can be checked at:https://learn.microsoft.com/en-us/azure/role-based-access-control/built-in-roles)

    "roleDefinitionIds": {
        "value": [
                "b24988ac-6180-42a0-ab88-20f7382dd24c",
                "18d7d88d-d35e-4fb5-a5c3-7773c20a72d9"
        ]
    }
  }
}

Session Hosts templates for the AVD Hostpool

The next template is for the creation of the session hosts with some extra agents that are needed for the deployment. This Bicep file also counts the session hosts that need to be created, so you can start multiple session hosts for your deployment.

@description('Name of the Hostpool to add the session hosts')
param name string

@description('Tags for the Session Hosts')
param tags object

@description('Prefix for the Session Hosts')
param vmPrefix string

@description('Region of the Session Hosts')
param location string = resourceGroup().location

@description('Number of session hosts to enroll')
param sessionhostscount int

@description('Adding the VNET to the Session Hosts with an ID')
param vnetId string

@description('Adding the Subnet name to the Session Hosts')
param subnetName string

@description('Sizing of the VM for the Session Hosts')
param VMsize string

@description('Local Admin name for creating the Session Hosts')
param localAdminUserName string

@description('Password users for the Local admin account')
@secure()
param localAdminUserPassword string

@description('Licensing type for the Session Hosts')
param licenseType string = 'Windows_Client'

@description('Active Directory Domain to join the Session Hosts')
param domain string

@description('Domin Join account for joining Session Hosts')
param domainjoinaccount string

@description('Password for the Domain Join account')
@secure()
param domainjoinaccountpassword string

@description('Domain join options, this field is needed to determine the options to join the Session Hosts. The default is 3')
param domainJoinOptions int = 3

@description('The OU path of the Session Hosts in Active Directory. Make sure you use the DN')
param ouPath string

@description('The ID of the managed identity')
param managedIdentityid string

var avSetSKU = 'Aligned'


// Connecting the Session Hosts to the right Hostpool with following information. Needed for the AVD agent.

resource hostPoolToken 'Microsoft.DesktopVirtualization/hostPools@2024-01-16-preview' existing = {
  name: name
}

resource sessionhostnic 'Microsoft.Network/networkInterfaces@2023-09-01' = [for i in range(0, sessionhostscount): {
  name: 'nic-${take(name, 10)}-${i +1}'
  location: location
  tags: tags
  properties: {
    ipConfigurations: [
      {
        name: 'ipconfig1'
        properties: {
          subnet: {
            id: '${vnetId}/subnets/${subnetName}'
          }
          privateIPAllocationMethod: 'Dynamic'
        }
      }
      
    ]
  }

}]

resource availabilitySet 'Microsoft.Compute/availabilitySets@2023-09-01' = {
  name: '${vmPrefix}-avs'
  location: location
  properties: {
    platformFaultDomainCount: 2
    platformUpdateDomainCount: 2
  }
  sku: {
    name: avSetSKU
  }
}

resource sessionHosts 'Microsoft.Compute/virtualMachines@2023-09-01' = [for i in range(0, sessionhostscount): {
  name: 'sh${take(name, 10)}-${i +1}'
  location: location
  tags: tags
  identity: {
    type: 'SystemAssigned'
  }
  properties: {
    licenseType: 'Windows_Client'
    hardwareProfile: {
      vmSize: VMsize
    }
    availabilitySet: {
      id: resourceId('Microsoft.Compute/availabilitySets', '${vmPrefix}-avs')
    }
    osProfile: {
      computerName: 'sh${take(name, 10)}-${i + 1}'
      adminUsername: localAdminUserName
      adminPassword: localAdminUserPassword
    }
    storageProfile: {
      imageReference: {
        publisher: 'MicrosoftWindowsDesktop'
        offer: 'Windows-11'
        sku: '23h2-avd'
        version: 'latest'
      }
      osDisk: {
        createOption: 'FromImage'
      }
    }
    networkProfile: {
      networkInterfaces: [
        {
          properties: {
            primary: true
          }
          id: sessionhostnic[i].id
        }
     ]
  }
}

  dependsOn: [
    sessionhostnic[i]
    availabilitySet
  ]
}]

resource domainjoinsessionhosts 'Microsoft.Compute/virtualMachines/extensions@2023-09-01' = [for i in range(0, sessionhostscount): {
  name: '${sessionHosts[i].name}/JoinDomain'
  location: location
  tags: tags
  properties: {
    publisher: 'Microsoft.Compute'
    type: 'JsonADDomainExtension'
    typeHandlerVersion: '1.3'
    autoUpgradeMinorVersion: true
    settings: {
      name: domain
      ouPath: ouPath
      user: domainjoinaccount
      restart: true
      options: domainJoinOptions
    }
    protectedSettings: {
      password: domainjoinaccountpassword
    }
  }
  dependsOn: [
    sessionHosts[i]
  ]
}]

resource avdagentsessionhosts 'Microsoft.Compute/virtualMachines/extensions@2023-09-01' = [for i in range(0, sessionhostscount): {
  name: '${sessionHosts[i].name}/AddSessionHost'
  location: location
  tags: tags
  properties: {
    publisher: 'Microsoft.Powershell'
    type: 'DSC'
    typeHandlerVersion: '2.73'
    autoUpgradeMinorVersion: true
    settings: {
      modulesUrl: 'https://raw.githubusercontent.com/Azure/RDS-Templates/master/ARM-wvd-templates/DSC/Configuration.zip'
      configurationFunction: 'Configuration.ps1\\AddSessionHost'
      properties: {
        hostPoolName: hostPoolToken.name
        registrationInfoToken: hostPoolToken.properties.registrationInfo.token
      }
    }
  }

  dependsOn: [
    domainjoinsessionhosts[i]
  ]
}]



resource azuremonitoringagent 'Microsoft.Compute/virtualMachines/extensions@2021-11-01' = [for i in range(0, sessionhostscount): {
  name: '${sessionHosts[i].name}/AzureMonitorWindowsAgent'
  location: location
  properties: {
    publisher: 'Microsoft.Azure.Monitor'
    type: 'AzureMonitorWindowsAgent'
    typeHandlerVersion: '1.0'
    autoUpgradeMinorVersion: true
    enableAutomaticUpgrade: true
    settings: {
      authentication: {
        managedIdentity: {
          'identifier-name': 'mi_res_id'
          'identifier-value': managedIdentityid
        }
      }
    }
  }
}]

Here we also need the main file to call the parameters file afterwards. It is not necessary when there is only one template file, but for the same structure I will use the same method.

// General parameters for multiple resources

@description('Naming the resource in the deployment')
param name string

@description('Azure region of the deployment')
param location string = resourceGroup().location

@description('Tags to add to the resources')
param tags object

@description('The ID of the managed identity')
param managedIdentityid string

//Parameter for deploying resources
@description('Parameter for the deploymentname time window, this will be visible in the Azure portal on the resource group')
param time string = replace(utcNow(), ':', '-')

// Session Hosts parameters

@description('Prefix for the Session Hosts')
param vmPrefix string

@description('Number of session hosts to enroll')
param sessionhostscount int

@description('Adding the VNET to the Session Hosts with an ID')
param vnetId string

@description('Adding the Subnet name to the Session Hosts')
param subnetName string

@description('Sizing of the VM for the Session Hosts')
param VMsize string

@description('Local Admin name for creating the Session Hosts')
param localAdminUserName string

@description('Password users for the Local admin account')
@secure()
param localAdminUserPassword string

@description('Active Directory Domain to join the Session Hosts')
param domain string

@description('Domin Join account for joining Session Hosts')
param domainjoinaccount string

@description('Password for the Domain Join account')
@secure()
param domainjoinaccountpassword string

@description('The OU path of the Session Hosts in Active Directory. Make sure you use the DN')
param ouPath string

@description('Resource Group Name for identity scope')
param miResourceGroupName string


module sessionhosts 'modules/sessionhost.bicep' = {
  name: 'sessionhosts-${time}'
  params: {
    location: location
    domain: domain
    domainjoinaccount: domainjoinaccount
    domainjoinaccountpassword: domainjoinaccountpassword
    localAdminUserName: localAdminUserName
    localAdminUserPassword: localAdminUserPassword
    managedIdentityid:  managedIdentityid
    name: name
    ouPath: ouPath
    sessionhostscount: sessionhostscount
    subnetName: subnetName
    tags:tags
    vmPrefix: vmPrefix
    VMsize: VMsize
    vnetId: vnetId
  }
}

The last part is the parameters file for the session hosts. Be aware that your are using passwords in your parameter file, but we are using a secure string so this will not be exportable in the deployment. Using a key vault is more secure, but takes more steps to deploy. This makes the deployment a little bit easier.

{
    "$schema": "https://schema.management.azure.com/schemas/2015-01-01/deploymentParameters.json#",
    "contentVersion": "1.0.0.0",
    "parameters": {

    // General parameters used for multiple resources

    "name": {
        "value": "m2cavd"
    },

    "tags": {
        "value": {
            "M2C": "AVD",
            "Environment": "TEST"
        }
    },

    "managedIdentityName": {
        "value": "m2cmanid"
    },


    // Session host parameters

    "domain": {
        "value": "m2c.local"
    },

    "domainjoinaccount": {
        "value": "domainjoin@m2c.local"
    },

    "domainjoinaccountpassword": {
        "value": "YourPasswordHere!"
    },

    "localAdminUserName": {
        "value": "adminavd"
    },

    "localAdminUserPassword": {
        "value": "YourPasswordHere!"
    },

    "ouPath": {
        "value": "OU=Pooled Desktop,OU=AVD,OU=Devices,OU=M2C,DC=m2c,DC=local"
    },

    "sessionhostscount": {
        "value": 2
    },

    "subnetName": {
        "value": "avdsubnet"
    },

    "vmPrefix": {
        "value": "m2cavd"
    },
    "VMsize": {
        "value": "Standard_D2as_v5"
    },

    "vnetId": {
        "value": "yourvnetid"
    }
  }
}

Now you have all the files created for the Bicep templates. It still needs to be deployed. So stay tuned for the next blog in this series, because deploying Bicep templates can be very satisfying when everything works out as expacted.

The templates can be used in your deployment, but be aware that I create the resource and location in the pipeline deployment. So thats why you don’t see them in the Bicep templates. This will be discussed in the next post.

Final Thoughts

Creating Bicep templates can be a long proces, but when you have something created it a solid environment that fits within the rest of the Azure infrastructureMicrosoft really invests in Bicep templates and the possibilities are endless, so these templates are just one way to deploy Azure Virtual Desktop. There are many ways to deploy AVD with Bicep templates, it can get very complex, but hopefully these templates are understandable. So you can make a start with Bicep templates.

Resources

Author

  • Mischa Sachse

    Mischa Sachse is one of the founders of the Cloud Experts Community. Would you like to join in the fun? Make sure to contact him via the mail button below or find out more about him on his personal website.

    View all posts

Leave a Reply

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