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
- You run multi-session pooled desktops (e.g. Windows 11 Enterprise multi-session) for a fleet of office workers and want the host pool, app group, and workspace defined as code.
- You hand out personal (assigned) desktops to developers or privileged users and need
AutomaticorDirectassignment captured in version control. - You publish RemoteApp streams (a line-of-business app, not a full desktop) and want the
RemoteAppapplication group managed alongside the pool. - You need repeatable DR or multi-region AVD: the same host-pool/workspace topology in a second region, differing only by inputs.
- You want the registration token generated and rotated by Terraform so an autoscaling session-host module can pull it from state instead of someone pasting it from the portal.
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 config — live/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 config — live/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
- Rotate and scope the registration token. Keep
registration_token_rotation_daysat or below Azure’s 27-day cap and let thetime_rotatingresource reissue it; never paste long-lived tokens into pipelines. Treat theregistration_tokenoutput as a secret (it is markedsensitive) and pass it to the session-host module via state, not logs. - Harden the RDP surface. Use
custom_rdp_propertiesto disable drive, clipboard, and printer redirection (drivestoredirect:s:;redirectclipboard:i:0) on internet-facing pooled desktops, and pair AVD with Conditional Access / MFA on the Entra ID app rather than relying on RDP settings alone. - Right-size for cost. Tune
maximum_sessions_allowedto your image’s CPU/RAM so pooled hosts pack efficiently, keepstart_vm_on_connect = trueso idle hosts deallocate, and drive scale-out with an autoscale scaling plan — paying for VMs that brokering keeps warm is the biggest AVD bill. - Separate the brokering layer from the hosts. This module owns the durable control-plane objects; keep session-host VMs/VMSS and the golden image in their own module so you can reimage or patch hosts without recreating the host pool, workspace, or RBAC.
- Name and tag for fleet operations. Use a consistent
hp-/ag-/ws-prefix scheme with environment and region suffixes (e.g.hp-office-prod-weu) and tag every object withenvironment,workload, andownerso cost reports and DR runbooks can slice the estate cleanly. - Validate before you go wide. Stand up a small pool with
validate_environment = trueto catch breaking AVD agent/service updates in a canary ring before they reach the production host pools.