IaC Azure

Terraform Module: Azure Virtual Desktop (AVD) — a reusable host pool, app group, and workspace stack

Quick take — Build a production-ready Azure Virtual Desktop host pool with Terraform and azurerm ~> 4.0: pooled/personal session hosts, registration tokens, an app group, and a workspace, all var-driven and ready to reuse. New here? Jump to the Quickstart below to deploy it in minutes; read on for how it works and when to reach for it.

Quickstart (copy-paste)

Minimal, runnable configuration — drop this in a .tf file and fill in the "..." placeholders (each required input is commented):

provider "azurerm" {
  features {}
}

module "virtual_desktop" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-desktop?ref=v1.0.0"

  host_pool_name         = "..."  # Name of the AVD host pool (3-64 chars).
  resource_group_name    = "..."  # Resource group that holds the AVD objects.
  location               = "..."  # Azure region for the AVD metadata objects.
  application_group_name = "..."  # Name of the application group.
}

Then terraform init && terraform apply. Every other input has a sensible default — see Inputs below to override behaviour.

What this module is

Azure Virtual Desktop (AVD) is Microsoft’s managed VDI and remote-app service. The control plane (host pools, application groups, workspaces, and the brokering/gateway that connect users to sessions) is run by Microsoft; you only own the session-host VMs and the configuration objects that describe how users land on them. Those configuration objects are exactly the pieces that drift, get hand-edited in the portal, and are painful to reproduce across dev/test/prod or across regions for DR.

This module wraps the AVD control-plane objects you almost always provision together — an azurerm_virtual_desktop_host_pool, a rotating registration token, an application group, and a workspace association — behind one clean, versioned interface. The session-host VMs themselves are deliberately left out: they belong in a separate scale-set / VMSS / image-pipeline module so you can rebuild or patch hosts without touching the brokering layer. What you get here is the stable spine of an AVD deployment: create it once, reference its outputs (host-pool name, registration token, app-group ID) from your session-host module and your RBAC assignments, and stamp it out per environment with nothing more than a different tfvars.

Wrapping it as a module also lets you enforce the non-obvious correctness rules in one place: load_balancer_type only applies to Pooled pools, personal_desktop_assignment_type only applies to Personal pools, and a registration token has to be regenerated before it expires or new hosts can’t join. Encoding those as validations and locals means consumers can’t build an invalid pool by accident.

When to use it

If you only need to spin up a couple of throwaway VMs with RDP for a demo, this is overkill — use a plain VM. Reach for this module when AVD is a managed, audited, multi-environment platform.

Module structure

terraform-module-azure-virtual-desktop/
├── versions.tf      # provider + Terraform version pins
├── main.tf          # host pool, registration token, app group, workspace
├── variables.tf     # all inputs with validation
└── outputs.tf       # ids, names, registration token

versions.tf

terraform {
  required_version = ">= 1.5.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
    time = {
      source  = "hashicorp/time"
      version = "~> 0.12"
    }
  }
}

main.tf

locals {
  # A pooled host pool needs a load balancer type; a personal pool must not set one.
  load_balancer_type = var.host_pool_type == "Pooled" ? var.load_balancer_type : "Persistent"

  # Only pooled pools accept a max-sessions cap; personal pools are 1:1.
  maximum_sessions_allowed = var.host_pool_type == "Pooled" ? var.maximum_sessions_allowed : null

  # Personal pools require an assignment type; pooled pools must leave it null.
  personal_desktop_assignment_type = var.host_pool_type == "Personal" ? var.personal_desktop_assignment_type : null
}

# Drives registration-token rotation: when this resource is replaced, a new token is issued.
resource "time_rotating" "registration" {
  rotation_days = var.registration_token_rotation_days
}

resource "azurerm_virtual_desktop_host_pool" "this" {
  name                = var.host_pool_name
  resource_group_name = var.resource_group_name
  location            = var.location

  type               = var.host_pool_type
  load_balancer_type = local.load_balancer_type

  friendly_name = var.friendly_name
  description   = var.description

  maximum_sessions_allowed         = local.maximum_sessions_allowed
  personal_desktop_assignment_type = local.personal_desktop_assignment_type

  validate_environment     = var.validate_environment
  start_vm_on_connect      = var.start_vm_on_connect
  custom_rdp_properties    = var.custom_rdp_properties
  preferred_app_group_type = var.preferred_app_group_type

  tags = var.tags
}

# Short-lived secret that session hosts use to join the pool. Rotates with time_rotating.
resource "azurerm_virtual_desktop_host_pool_registration_info" "this" {
  hostpool_id     = azurerm_virtual_desktop_host_pool.this.id
  expiration_date = time_rotating.registration.rotation_rfc3339
}

resource "azurerm_virtual_desktop_application_group" "this" {
  name                = var.application_group_name
  resource_group_name = var.resource_group_name
  location            = var.location

  type          = var.application_group_type # "Desktop" or "RemoteApp"
  host_pool_id  = azurerm_virtual_desktop_host_pool.this.id
  friendly_name = var.application_group_friendly_name
  description   = var.application_group_description

  # Hides the published session desktop in RemoteApp scenarios.
  default_desktop_display_name = var.application_group_type == "Desktop" ? var.default_desktop_display_name : null

  tags = var.tags
}

resource "azurerm_virtual_desktop_workspace" "this" {
  count = var.create_workspace ? 1 : 0

  name                = var.workspace_name
  resource_group_name = var.resource_group_name
  location            = var.location

  friendly_name = var.workspace_friendly_name
  description   = var.workspace_description

  tags = var.tags
}

resource "azurerm_virtual_desktop_workspace_application_group_association" "this" {
  count = var.create_workspace ? 1 : 0

  workspace_id         = azurerm_virtual_desktop_workspace.this[0].id
  application_group_id = azurerm_virtual_desktop_application_group.this.id
}

variables.tf

variable "host_pool_name" {
  type        = string
  description = "Name of the AVD host pool."

  validation {
    condition     = can(regex("^[A-Za-z0-9._-]{3,64}$", var.host_pool_name))
    error_message = "host_pool_name must be 3-64 chars: letters, digits, '.', '_' or '-'."
  }
}

variable "resource_group_name" {
  type        = string
  description = "Resource group that holds the AVD objects."
}

variable "location" {
  type        = string
  description = "Azure region for the AVD metadata objects (e.g. westeurope)."
}

variable "host_pool_type" {
  type        = string
  description = "Host pool type: 'Pooled' (multi-session) or 'Personal' (1:1)."
  default     = "Pooled"

  validation {
    condition     = contains(["Pooled", "Personal"], var.host_pool_type)
    error_message = "host_pool_type must be 'Pooled' or 'Personal'."
  }
}

variable "load_balancer_type" {
  type        = string
  description = "Load balancing for Pooled pools: 'BreadthFirst', 'DepthFirst' or 'Persistent'. Ignored for Personal."
  default     = "BreadthFirst"

  validation {
    condition     = contains(["BreadthFirst", "DepthFirst", "Persistent"], var.load_balancer_type)
    error_message = "load_balancer_type must be 'BreadthFirst', 'DepthFirst' or 'Persistent'."
  }
}

variable "maximum_sessions_allowed" {
  type        = number
  description = "Max concurrent sessions per session host (Pooled only, 1-999999)."
  default     = 16

  validation {
    condition     = var.maximum_sessions_allowed >= 1 && var.maximum_sessions_allowed <= 999999
    error_message = "maximum_sessions_allowed must be between 1 and 999999."
  }
}

variable "personal_desktop_assignment_type" {
  type        = string
  description = "Assignment for Personal pools: 'Automatic' or 'Direct'. Ignored for Pooled."
  default     = "Automatic"

  validation {
    condition     = contains(["Automatic", "Direct"], var.personal_desktop_assignment_type)
    error_message = "personal_desktop_assignment_type must be 'Automatic' or 'Direct'."
  }
}

variable "preferred_app_group_type" {
  type        = string
  description = "What users see by default: 'Desktop' or 'RailApplications' (RemoteApp)."
  default     = "Desktop"

  validation {
    condition     = contains(["Desktop", "RailApplications", "None"], var.preferred_app_group_type)
    error_message = "preferred_app_group_type must be 'Desktop', 'RailApplications' or 'None'."
  }
}

variable "friendly_name" {
  type        = string
  description = "Display name for the host pool."
  default     = null
}

variable "description" {
  type        = string
  description = "Free-text description of the host pool."
  default     = null
}

variable "validate_environment" {
  type        = bool
  description = "Whether the pool is in the validation (insider) ring for earlier service updates."
  default     = false
}

variable "start_vm_on_connect" {
  type        = bool
  description = "Power on a deallocated session host when a user connects (Start VM on Connect)."
  default     = true
}

variable "custom_rdp_properties" {
  type        = string
  description = "Semicolon-delimited RDP property string, e.g. 'audiocapturemode:i:1;drivestoredirect:s:'."
  default     = null
}

variable "registration_token_rotation_days" {
  type        = number
  description = "How many days the session-host registration token stays valid before rotating (Azure max 27)."
  default     = 27

  validation {
    condition     = var.registration_token_rotation_days >= 1 && var.registration_token_rotation_days <= 27
    error_message = "registration_token_rotation_days must be between 1 and 27 (Azure limit)."
  }
}

variable "application_group_name" {
  type        = string
  description = "Name of the AVD application group."
}

variable "application_group_type" {
  type        = string
  description = "Application group type: 'Desktop' (full desktop) or 'RemoteApp' (published apps)."
  default     = "Desktop"

  validation {
    condition     = contains(["Desktop", "RemoteApp"], var.application_group_type)
    error_message = "application_group_type must be 'Desktop' or 'RemoteApp'."
  }
}

variable "application_group_friendly_name" {
  type        = string
  description = "Display name for the application group."
  default     = null
}

variable "application_group_description" {
  type        = string
  description = "Free-text description of the application group."
  default     = null
}

variable "default_desktop_display_name" {
  type        = string
  description = "Label shown for the published session desktop (Desktop app groups only)."
  default     = null
}

variable "create_workspace" {
  type        = bool
  description = "Create a workspace and associate the app group with it. Set false to attach to an existing workspace elsewhere."
  default     = true
}

variable "workspace_name" {
  type        = string
  description = "Name of the AVD workspace (used when create_workspace = true)."
  default     = null
}

variable "workspace_friendly_name" {
  type        = string
  description = "Display name for the workspace."
  default     = null
}

variable "workspace_description" {
  type        = string
  description = "Free-text description of the workspace."
  default     = null
}

variable "tags" {
  type        = map(string)
  description = "Tags applied to all AVD objects."
  default     = {}
}

outputs.tf

output "host_pool_id" {
  description = "Resource ID of the AVD host pool."
  value       = azurerm_virtual_desktop_host_pool.this.id
}

output "host_pool_name" {
  description = "Name of the AVD host pool (needed by the session-host join step)."
  value       = azurerm_virtual_desktop_host_pool.this.name
}

output "registration_token" {
  description = "Registration token session hosts use to join the pool. Sensitive and rotates."
  value       = azurerm_virtual_desktop_host_pool_registration_info.this.token
  sensitive   = true
}

output "registration_token_expiration" {
  description = "RFC3339 expiry of the current registration token."
  value       = azurerm_virtual_desktop_host_pool_registration_info.this.expiration_date
}

output "application_group_id" {
  description = "Resource ID of the application group (attach RBAC role assignments here)."
  value       = azurerm_virtual_desktop_application_group.this.id
}

output "application_group_name" {
  description = "Name of the application group."
  value       = azurerm_virtual_desktop_application_group.this.name
}

output "workspace_id" {
  description = "Resource ID of the workspace, or null when create_workspace = false."
  value       = try(azurerm_virtual_desktop_workspace.this[0].id, null)
}

How to use it

resource "azurerm_resource_group" "avd" {
  name     = "rg-avd-prod-weu"
  location = "westeurope"
}

module "virtual_desktop_avd_office" {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-desktop?ref=v1.0.0"

  host_pool_name      = "hp-office-prod-weu"
  resource_group_name = azurerm_resource_group.avd.name
  location            = azurerm_resource_group.avd.location

  host_pool_type           = "Pooled"
  load_balancer_type       = "BreadthFirst"
  maximum_sessions_allowed = 12
  start_vm_on_connect      = true
  preferred_app_group_type = "Desktop"

  # Disable drive/clipboard redirection and force fresh credentials.
  custom_rdp_properties = "drivestoredirect:s:;redirectclipboard:i:0;enablecredsspsupport:i:1"

  application_group_name       = "ag-office-desktop-prod-weu"
  application_group_type       = "Desktop"
  default_desktop_display_name = "KloudVin Office Desktop"

  workspace_name          = "ws-office-prod-weu"
  workspace_friendly_name = "KloudVin Office"

  registration_token_rotation_days = 14

  tags = {
    environment = "prod"
    workload    = "avd-office"
    owner       = "eus-platform"
  }
}

# Downstream: grant a security group the "Desktop Virtualization User" role on the app group.
resource "azurerm_role_assignment" "office_users" {
  scope                = module.virtual_desktop_avd_office.application_group_id
  role_definition_name = "Desktop Virtualization User"
  principal_id         = var.office_users_group_object_id
}

# Downstream: hand the registration token to the session-host extension so VMs join the pool.
output "session_host_join_token" {
  value     = module.virtual_desktop_avd_office.registration_token
  sensitive = true
}

With Terragrunt

Terragrunt keeps this module DRY across environments — define the backend and provider once in a root config, then a thin terragrunt.hcl per environment supplies only the inputs that differ.

1. Root configlive/terragrunt.hcl (inherited by every module):

remote_state {
  backend = "azurerm"
  generate = { path = "backend.tf", if_exists = "overwrite" }
  config = {
    # ...azurerm state bucket/container + key per path...
  }
}

2. Module configlive/prod/virtual_desktop/terragrunt.hcl:

include "root" {
  path = find_in_parent_folders()
}

terraform {
  source = "git::https://dev.azure.com/teknohut/kloudvin/_git/terraform-modules//terraform-module-azure-virtual-desktop?ref=v1.0.0"
}

inputs = {
  host_pool_name = "..."
  resource_group_name = "..."
  location = "..."
  application_group_name = "..."
}

3. Deploy one environment, or roll out all modules together:

cd live/prod/virtual_desktop && terragrunt apply        # this module
terragrunt run-all apply                      # every module under live/prod

Why Terragrunt here: the backend and provider live in one place instead of being copy-pasted into every module; inputs is overridden per environment (dev / stage / prod) without forking the module; and run-all orchestrates dependencies across modules. Reach for it once you have more than one environment or more than a handful of modules — for a single stack, the plain Quickstart above is enough.

Inputs

Name Type Default Required Description
host_pool_name string Yes Name of the AVD host pool (3-64 chars).
resource_group_name string Yes Resource group that holds the AVD objects.
location string Yes Azure region for the AVD metadata objects.
host_pool_type string "Pooled" No Pooled (multi-session) or Personal (1:1).
load_balancer_type string "BreadthFirst" No BreadthFirst/DepthFirst/Persistent; pooled only.
maximum_sessions_allowed number 16 No Max concurrent sessions per host (pooled only).
personal_desktop_assignment_type string "Automatic" No Automatic or Direct; personal only.
preferred_app_group_type string "Desktop" No Default user experience: Desktop/RailApplications/None.
friendly_name string null No Display name for the host pool.
description string null No Free-text host pool description.
validate_environment bool false No Place the pool in the validation (insider) ring.
start_vm_on_connect bool true No Power on a deallocated host when a user connects.
custom_rdp_properties string null No Semicolon-delimited RDP property string.
registration_token_rotation_days number 27 No Days the registration token stays valid (max 27).
application_group_name string Yes Name of the application group.
application_group_type string "Desktop" No Desktop or RemoteApp.
application_group_friendly_name string null No Display name for the application group.
application_group_description string null No Free-text application group description.
default_desktop_display_name string null No Label for the published desktop (Desktop groups only).
create_workspace bool true No Create a workspace and associate the app group.
workspace_name string null No Name of the workspace (when create_workspace = true).
workspace_friendly_name string null No Display name for the workspace.
workspace_description string null No Free-text workspace description.
tags map(string) {} No Tags applied to all AVD objects.

Outputs

Name Description
host_pool_id Resource ID of the AVD host pool.
host_pool_name Name of the host pool (needed by the session-host join step).
registration_token Sensitive, rotating token session hosts use to join the pool.
registration_token_expiration RFC3339 expiry of the current registration token.
application_group_id Resource ID of the application group (attach RBAC here).
application_group_name Name of the application group.
workspace_id Resource ID of the workspace, or null when not created.

Enterprise scenario

A financial-services firm migrating 4,000 branch staff off physical desktops needs a locked-down, auditable VDI estate. The platform team consumes this module twice from a single root config — once with host_pool_type = "Pooled" and hardened custom_rdp_properties (no drive or clipboard redirection) for general office workers, and once with host_pool_type = "Personal" and personal_desktop_assignment_type = "Direct" for traders who need a persistent, individually-assigned desktop. Both pools feed their registration_token output into an autoscaling session-host module, and Desktop Virtualization User role assignments are wired to Entra ID groups via the application_group_id output, so onboarding a new branch is a tfvars change and a group membership, fully captured in the audit trail.

Best practices

TerraformAzureVirtual Desktop (AVD)ModuleIaC
Need this built for real?

Vinod is a Senior Cloud Architect (22+ yrs) — available for Azure / AWS / GCP architecture, landing zones, and migrations.

Work with me

Comments

Keep Reading