ARM AVD with Terraform



Deploying Azure Virtual Desktop with Terraform


This article has been written in collaboration with my colleagues @Jensheerin , @Stefan Georgiev and Julie NG.


Terraform is a tool that enables you to completely automate infrastructure builds through configuration files. It provides versioning for configurations, which makes it easy to deploy and maintain your existing Azure Virtual Desktop deployments on Microsoft Azure.

This article provides an overview of how to use Terraform to deploy a simple Azure Virtual Desktop environment.   This is to deploy ARM AVD, not AVD Classic.


There are several pre-requisites required to deploy Azure Virtual Desktop, which we will assume are already in place. 

  1. Ensure that you meet the requirements for Azure Virtual Desktop
  2. Terraform must be installed and configured as outlined here.


If you are completely new to Azure Virtual Desktop, please check them out here:

What is Azure Virtual Desktop? - Azure | Microsoft Docs


There are several topics that should be considered when creating a production Azure Virtual Desktop environment, that we haven’t been able to include in the scope of this article, such as security, monitoring, BCDR and image build.   This article aims to get you started with building a PoC for Azure Virtual Desktop via Terraform.  


All the code in this article can be found in the repo:

RDS-Templates/wvd-sh/terraform-azurerm-azuresvirtualdesktop at master · Azure/RDS-Templates (github....



Note: Terraform is an open source tool hosted in GitHub. As such, it is published "as is" with no implied support from Microsoft or any other organization. However, we would like to welcome you to open issues using GitHub issues to collaborate toward future improvements to the tool.


AVD Components

To deploy AVD we will need to understand what components are required.  We’re assuming that your pre-requisites are already in place. 

  • Active Directory - in this worked example, we are using ‘on-prem’ AD running on DCs in a separate VNet.  The code could easily be modified to use AADDS though.
  • Users in AAD that will be given access to AVD
  • A VM Image (or you can use a marketplace image)

Components we will deploy in this article

  • Virtual Desktop Environment
  • Networking Infrastructure
  • Session Hosts
  • Profile Storage
  • Role Based Access Control

Our architecture should look like the below once completed (sections in white are pre-reqs, grey will be deployed).





Setting up Terraform


You’ll need to authenticate to Azure to run the templates – the steps to do that are here:


If you want to use Visual Studio Code please have a look at this article.


Once your environment is ready, we can start to understand how to deploy all of the required resources.


1. AVD environment


First up we will deploy the environment for Azure Virtual Desktop.  


In this section we will deploy the following resources:


  • Resource Group
  • Workspace
  • Hostpool
  • Hostpool registration expiration date (create a time_rotating resource)
  • Application Group (our DAG)
  • Application Group association to workspace




Before we create templates for the resources we need to configure the Azure Provider.

To do this we will create a file and add the following:  



terraform {

  required_providers {

    azurerm = {

      source = "hashicorp/azurerm"

      version = ""~>2.0""




provider "azurerm" {

  features {}



The full code for can be found here.


The Terraform documentation for AVD is here: azurerm_virtual_desktop_workspace | Resources | hashicorp/azurerm | Terraform Registry


Then to create the resources, first create and start adding resources in the format:


resource "azurerm_virtual_desktop_workspace" "workspace" {

  name                =  var.workspace

  location            =  var.deploy_location

  resource_group_name =


  friendly_name       = "${var.prefix} Workspace"

  description         = "${var.prefix} Workspace"



Note that there are several dependencies in the order that resources are created.  We can specify those using the following


depends_on          = [azurerm_virtual_desktop_host_pool.hostpool,azurerm_virtual_desktop_workspace.workspace]


In this case we are specifying the dependency for provisioning the Desktop Application group – that the hostpool and workspace must already exist before we try to create this resource.


We have also referenced some variables in here, so let’s create a file for those now and add our variables.  They will be in the following form:


variable "deploy_location" {

  type        = string

  default     = "West Europe"

  description = "location"



We will also need to add variables for:

  • Resource group name
  • Prefix (this will be appended to resources such as session hosts)
  • Host pool name



A full list of the variables that are referenced are listed at the end of the article in step 7.


We can deploy at this point and it will create the basic AVD components, but no session hosts.


To add the session hosts, we need to ensure we can access Active Directory.   For this example we are assuming that we are using AD rather than AADDS.  We are also assuming you have a domain controller in an Azure VNet. 


 2. Networking infrastructure


We will create a new VNet for our session hosts and peer it to our AD VNet.  we’ve also included an NSG here with a sample rule – I’d strongly suggest modifying them to meet your own security requirements.


Components we will deploy here:

  • Session Host Virtual Network
  • Session Host Subnet
  • NSG
  • NSG – Subnet association
  • VNet Peering to Active Directory VNet


The full template can be found in


The new concept we have here is using data to retrieve the properties of our existing Active Directory (Hub) VNet.  We can then pass the ID to new peering we are creating.


data "azurerm_virtual_network" "ad_vnet_data" {

  name = var.ad_vnet

  resource_group_name = var.ad_rg



resource "azurerm_virtual_network_peering" "peer1" {

  name                      = "peer_avd_ad"

  resource_group_name       =

  virtual_network_name      =

  remote_virtual_network_id = data.azurerm_virtual_network.



resource "azurerm_virtual_network_peering" "peer2" {

  name                      = "peer_avd_ad"

  resource_group_name       = var.ad_rg

  virtual_network_name      = var.ad_vnet

  remote_virtual_network_id =



Again, we can see that we have referenced several variables, so you’ll need to add the following to your file:


  • ad_vnet  – the name of the VNet containing our Domain Controllers
  • ad_rg  - resource group containing DCs
  • dns_servers – custom DNS servers that we’re using for our new VNet
  • vnet_range – Address range for our new VNet
  • subnet_range - Address range for our new subnet



Now we should have all our basic infrastructure in place, we can move onto the session hosts.


3. Session Hosts


Here we are deploying and configuring our session hosts.  In this example, we will create a new Terraform config file, to do this.  Full code is here. We will also add our variables to the file.


Components to deploy in this section:

  • NIC for each session host
  • Session Host VM(s)
  • Domain-join VM extension
  • Dsc VM extension to register session host
  • Random strong for local vm password
  • Local variable for registration token


This part is slightly more complex than the infrastructure deployment.   The new concepts in this section are covered below.


We firstly need to create a local variable for our registration_info token to allow us to register the VM to the host pool.  This is later passed as a protected setting to the dsc extension resource.


locals {

  registration_token = azurerm_virtual_desktop_host_pool.hostpool.registration_info[0].token



We’re also creating a random local password – which needs to meet the AVD requirements:


resource "random_string" "avd_local_password" {

  count            = "${var.rdsh_count}"

  length           = 16

  special          = true

  min_special      = 2

  override_special = "*!@#?"



In the password section you will see us referencing rdshcount.  This allows us to deploy a variable number of VMs to our host pool.  Using this counter will be used for the VM, the NICs, local passwords and the extensions.  We are also using the count meta-argument to refer to specific instances:


resource "azurerm_windows_virtual_machine" "avd_vm" {

  count                 = "${var.rdsh_count}"

  name                  = "${var.prefix}-${count.index + 1}"

  resource_group_name   = var.rg_name

  location              = var.deploy_location

  size                  = var.vm_size

  network_interface_ids = ["${azurerm_network_interface.AVD_vm_nic.*.id[count.index]}"]

  provision_vm_agent    = true

    admin_username = "${var.local_admin_username}"

    admin_password = "${random_string.AVD-local-password.*.result[count.index]}"


  os_disk {

    name                 = "${lower(var.prefix)}-${count.index +1}"

    caching              = "ReadWrite"

    storage_account_type = "Standard_LRS"



  source_image_reference {

    publisher = "MicrosoftWindowsDesktop"

    offer     = "Windows-10"

    sku       = "20h2-evd"                                

    version   = "latest"


depends_on = [azurerm_resource_group.rg, azurerm_network_interface.AVD_vm_nic]



The VM resource is also where we specific the source image for the build. If you need a different market place image you can get the image SKU details using:


Get-AzVMImageSku -Location <location> -PublisherName MicrosoftWindowsDesktop -Offer windows-10


(or -Offer office-365 if you want the image including M365 apps).


Deploying a custom image with the shared image gallery is a topic for a follow up article.


The additional variables we need to specify now are:


  • rdsh_count
  • domain_name
  • domain_user_upn
  • domain_password
  • vm_size
  • ou_path
  • local_admin_username



4. Profile Storage


For this example we’ll deploy our profile storage using Azure Files.   Step 6 has the steps to configure NetApp Files if you prefer this option. 


To do this we’ll need to deploy the following resources:

  • A dedicated resource group for our Storage account
  • Azure File Storage account
  • Azure Storage Share
  • Assign AAD group to the Storage (Storage File Data SMB Share Contributor)

We will deploy a new resource group.   We are using a random string to generate a globally unique name for our Storage account.


We are creating a file called for this (and the full code is included here).


We are appending a random string to the storage account name to ensure uniqueness – as such we also use the output command so that we can see the name of our new storage account.  We can use the file to define our outputs.


output "storage_account_name" {

  value =



Further configuration will be needed to enable AD authentication if you choose that direction and to configure NTFS permissions of SMB





Now that we have all our infrastructure deployed, let us give our users access.  Again, we will create a new config file for this –


This can also be modified to assign users to custom roles, or to the other desktop virtualization roles that are already built in: Built-in roles Azure Virtual Desktop - Azure | Microsoft Docs


The components we’re creating here are:

  • Azure Active Directory Group
  • AAD group member
  • AAD role assignment



Before we start, we’ll need to add the azuread provider to our list of required providers in our as we need to use this for some of the AAD resources.


terraform {

  required_providers {

    azurerm = {

      source = "hashicorp/azurerm"

      version = "~>2.0"


    azuread = {

      source = "hashicorp/azuread"





To assign the RBAC permissions we need to pass a list of existing AAD Users and then add these to a new AAD group that we are creating.


We will use the AzureAD_user and azurerm_role_definition datasources to retrieve information about our users and the role we’re assigning (in this case the builtin Desktop Virtualization user).



data "azuread_user" "aad_user" {

  for_each            = toset(var.avd_users)

  user_principal_name = format("%s", each.key)



data "azurerm_role_definition" "role_def" {

  name = "Desktop Virtualization User"



We’re also going to use for_each to loop through that list of users (both when getting the UPN from AAD and when adding to the group).


resource "azuread_group_member" "aad_group_member" {

  for_each         = data.azuread_user.aad_user

  group_object_id  =

  member_object_id = each.value["id"]



Lastly we’ll scope the role assignment to the application group we created at the start and apply it to the group containing our users.


resource "azurerm_role_assignment" "role" {

  scope              =

  role_definition_id =

  principal_id       =




We also need to add the 2 new variables:

  • aad_group_name
  • avd_users


avd_users will be an array to allow us to pass multiple users.  Up to now we have either specified default values for our variables or will pass them during deployment.  To make things simpler we will create an env.tfvars file to pass our environment specific variables.   You can add as many (or few) pre-configured variables here, but keep security in mind if you are putting confidential data in there.


A sample might look like:


deploy_location = "west europe"

rg_name         = "avd-resources-rg"


vnet_range =  [""]

subnet_range = [""]


prefix = "avd"


avd_users = [




dns_servers = ["", ""]




6. NetApp Storage


As an alternate to Azure Files you also have the option to deploy NetApp Storage for Azure Virtual Desktop profiles. To use NetApp Files you need to request access Register for Azure NetApp Files | Microsoft Docs.



To deploy the storage we’ll need to deploy the following resources:

  • A dedicated subnet for NetApp
  • NetApp storage Account
  • NetApp storage Pool
  • NetApp storage Volume


For simplicity we’ll deploy our subnet to the same Vnet we created earlier, and will use the same resource group and location variables.   You may want separate resource groups and/or more complex networking in a production deployment.


We are creating a file called for this and the full code can be found in the folder options/netapp.


We also need to add some new variables (and you’ll probably want to update the default values as well):


  • NetApp_acct_name
  • NetApp_pool_name
  • NetApp_volume_name
  • NetApp_smb_name
  • NetApp_volume_path
  • NetApp_subnet_name
  • NetApp_Address




Now we should have created 9 Files:

  • (or
  • defaults.tfvars



7. Variables


All of the variables that we have referenced so far are described here (they are also in






Name of the Resource Group in which to deploy these resources




West Europe


Name of the Azure Virtual Desktop host pool



Name of domain controller VNet



Custom DNS configuration



Address range for deployment VNet



Address range for session host subnet



The resource group for AD VM



Azure Active Directory Group for AVD users



Number of AVD machines to deploy



Prefix of the name of the AVD machine(s)



Name of the domain to join



Username for domain join (do not include domain name as this is appended



Password of the user to authenticate with the domain



Size of the machine to deploy



The ou path for AD



The local admin username for the VM



The NetApp account name



The NetApp pool name



The NetApp volume name



The NetApp smb name



The NetApp volume path



The NetApp subnet name



The Address range for NetApp Subnet



Note: Variables in italic are optional and only needed if you are deploying NetApp Files, these are included only in the variables template in the netapp folder.


8. Deploy!


The templates can be downloaded from github if you now want to deploy this yourself.  There are also some additional configuration files for other functionality that we hope to cover in further articles soon.







Once Terraform is setup and you have created your Terraform templates, the first step is to initialize Terraform. This step ensures that Terraform has all the prerequisites to build your template in Azure.


terraform init


The next step is to have Terraform review and validate the template. This step compares the requested resources to the state information saved by Terraform and then outputs the planned execution. The Azure resources aren't created at this point. An execution plan is generated and stored in the file specified by the -out parameter.


We also need to pass our variable definitions file during the plan.   We can either load it automatically by renaming env.tfvars as terraform.tfvars OR We then use the following to create the execution plan:


terraform plan -out terraform_azure.tfplan


If you don’t rename your variable file, use:


terraform plan -var-file defaults.tfvars -out terraform_azure.tfplan


Note: When you're ready to build the infrastructure in Azure, apply the execution plan - this will deploy the resources:


terraform apply terraform_azure.tfplan


If you update the templates after you have deployed, you will need to rerun the plan and apply steps for them to reflect in Azure.  


Troubleshooting Terraform deployment


Terraform deployment can fail in three main categories:

  1. Issues with Terraform code
  2. Issues with Desired State Configuration (DSC)
  3. Conflict with existing resources


Issues with Terraform code


While it is rare to have issues with the Terraform code it is still possible, however most often errors are due to bad input in

  • If there are errors in the Terraform code, please file a GitHub issue.
  • If there are warning in the Terraform code feel free to ignore or address for your own instance of that code.
  • Using Terraform error messages it's a good starting point towards identifying issues with input variables

Issues with DSC


To troubleshoot this type of issue, navigate to the Azure portal and if needed reset the password on the VM that failed DSC. Once you are able to log in to the VM review the log files following the guidance here: Troubleshooting DSC - PowerShell | Microsoft Docs


Conflict with Existing resources


If you have previously deployed resources with the same name, you may see a deployment failure.  Deployment will stop if any failures occur.  You can use:

Terraform destroy

To clean up resources that were created by the Terraform Apply command.  You can pass it the same options as the apply command.


The destroy command may fail trying to delete the subnet if associated resources have not been deleted first.  In this case you may need to manually delete resources associated with the subnet before running destroy, or you can delete the whole resource group manually.




9. Final Configuration


You’ll notice we didn’t configure the session hosts to use our profile storage at any point.  There is an assumption that we are using GPO to manage FSLogix across our host pools as documented here: Use FSLogix Group Policy Template Files - FSLogix | Microsoft Docs.  At a minimum you’ll need to configure the registry keys to enable FSLogix and configure the VHD Location to the NetApp Share URI: Profile Container registry configuration settings - FSLogix | Microsoft Docs


If not using GPO, the registry keys could be manually added as part of the build to the session host.


Please comment below if you have any questions or feedback!

2 Replies
@EmilyMclaren , waiting for a second part - automating the above via Azure Devops yaml pipelines )
Blowing my own trumpet a little here, but here's my post on doing this completely AAD with Intune.