Skip to content

Latest commit

 

History

1 Commit

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

☁️ Azure Virtual Desktop Workspace Terraform Module

Creates the Azure Virtual Desktop workspace — the feed a user's Remote Desktop client subscribes to. Targets hashicorp/azurerm ~> 4.0.

Terraform azurerm Module Type Resources Caveat


🧩 Overview

  • 🖥️ Manages azurerm_virtual_desktop_workspace — the feed that turns application groups into something a person can see.
  • 🚫 Publishes nothing by itself. Attaching an application group is a separate resource.
  • 🔒 Inverts the provider's public-access default to false, and states exactly what that costs.
  • ✍️ Reports that friendly_name and description are always sent, so omitting one clears it.
  • 📏 Mirrors the provider's name rule exactly, including the asymmetry that a name may end in an underscore.
  • 🌍 Names the confusion worth naming: the workspace's region is metadata, not where sessions run.

💡 Why it matters: a workspace is the one Azure Virtual Desktop object users actually interact with, and it is deceptively inert. It creates no compute, publishes nothing until an association exists, authorises nobody, and quietly clears its own display name if you omit the argument. This module makes each of those explicit rather than leaving them to be discovered.


❤️ Support this project

If this module saved you time:


🗺️ Where this fits in the family

flowchart TB
    RG["terraform-azurerm-resource-group"]
    HP["terraform-azurerm-virtual-desktop-host-pool"]
    AG["terraform-azurerm-virtual-desktop-application-group"]
    WS["terraform-azurerm-virtual-desktop-workspace"]
    ASSOC["azurerm_virtual_desktop_workspace_application_group_association"]
    RA["terraform-azurerm-role-assignments"]
    PE["terraform-azurerm-private-endpoint"]

    RG -->|"name to resource_group_name"| WS
    RG -->|"name to resource_group_name"| HP
    HP -->|"id to host_pool_id"| AG
    WS -->|"id to workspace_id"| ASSOC
    AG -->|"id to application_group_id"| ASSOC
    AG -->|"id to scope -- Desktop Virtualization User"| RA
    WS -.->|"required when the feed is not public"| PE

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class WS self
    class ASSOC keystone
    class RG,HP,AG,RA,PE ext
Loading

The association in the centre is the keystone of the relationship, not of this module — it is a separate resource that joins a workspace to an application group, and neither side owns it. Read the dotted edge as a requirement rather than a data flow: with public access disabled, a private endpoint is what makes the feed reachable at all, and this module neither creates it nor can see it.


🧬 What this module builds

flowchart TB
    V1["name -- 3-64, may end in an underscore"]
    V2["resource_group_name and location"]
    V3["friendly_name -- what users SEE"]
    V4["description"]
    V5["public_network_access_enabled -- INVERTED to false"]
    V6["tags and timeouts"]

    THIS["azurerm_virtual_desktop_workspace.this"]

    O1["id -- consumed by the application group association"]
    O2["private_link_is_required_for_the_feed"]
    O3["feed_is_publicly_reachable"]
    O4["posture -- publishes nothing by itself"]

    V1 --> THIS
    V2 --> THIS
    V3 --> THIS
    V4 --> THIS
    V5 --> THIS
    V6 --> THIS
    THIS --> O1
    THIS --> O2
    THIS --> O3
    THIS --> O4

    classDef self fill:#0078D4,stroke:#004578,color:#ffffff
    classDef keystone fill:#004578,stroke:#002B4A,color:#ffffff
    classDef ext fill:#F3F2F1,stroke:#8A8886,color:#201F1E
    class THIS keystone
    class O1 self
    class V1,V2,V3,V4,V5,V6,O2,O3,O4 ext
Loading

Resource inventory

Resource Count Notes
azurerm_virtual_desktop_workspace 1 (this) A metadata record. It creates no compute and publishes nothing until an application group is associated with it.

✅ Provider / Versions

Requirement Value
Terraform >= 1.12.0
hashicorp/azurerm ~> 4.0
Provider block None in this module. The caller configures provider "azurerm" { features {} }, including authentication.

Schema notes that bite — each verified against the live provider schema and the resource's own source:

  • 🔴 friendly_name and description are ALWAYS sent. The provider passes both on every write whether or not they are configured, so omitting one sends an empty string rather than leaving the current value alone. A workspace adopted by import and applied without them has its display name cleared.
  • 🔴 This module inverts public_network_access_enabled to false. The provider defaults it to true. The divergence follows this suite's secure-by-default rule and matches the already-authored host-pool module — but it is not free: see the Design Principles table.
  • ⚠️ The name rule is invisible in the published schema — 3 to 64 characters, letters, digits, dots, dashes and underscores, beginning with a letter or digit and ending with a letter, digit or underscore. So ws_prod_ is legal and ws-prod- is not.
  • ⚠️ The host pool expresses the same access idea as a four-valued STRING — Disabled, EnabledForSessionHostsOnly, EnabledForClientsOnly, Enabled — where this resource uses a boolean.
  • ⚠️ The read treats any absent or non-Enabled value as public, so an older workspace whose property the service does not return reads back as public.
  • ℹ️ The workspace's region is metadata, not where sessions run.
  • ℹ️ The create is a single synchronous call, not a polled long-running operation, despite the hour-long default timeouts.
  • ℹ️ The delete takes a provider-internal lock on the workspace name.
  • ℹ️ An import guard exists, and the resource carries a state upgrader (schema version 1 from 0).
  • ℹ️ All four timeouts are honoured, and location is normalised.

🔑 Required Azure RBAC Roles / Permissions

Least privilege, at the smallest scope that works.

Scope Permission Why
The resource group Microsoft.DesktopVirtualization/workspaces/read Refresh and plan.
The resource group Microsoft.DesktopVirtualization/workspaces/write Create and update. The workspace does not exist before the first apply, so the grant cannot be scoped to it.
The resource group Microsoft.DesktopVirtualization/workspaces/delete Destroy.
— Built-in fit: Desktop Virtualization Contributor at the resource group. The least-privilege built-in that covers workspace management; Contributor grants far more.

🔒 No credential of any kind is accepted or emitted by this module. A workspace carries no key, token or password.

ℹ️ Who may USE the feed is decided elsewhere. Access comes from Entra ID role assignments scoped to the application groups this workspace publishes — typically Desktop Virtualization User — and those are a separate resource.


Azure Prerequisites

  • The Microsoft.DesktopVirtualization resource provider registered in the subscription.
  • Nothing else for the workspace itself. It is a metadata record and creates no compute.
  • For the default public_network_access_enabled = false: Azure Virtual Desktop Private Link — a private endpoint for this workspace's feed, a private endpoint for the host pool's connections, and DNS that resolves the AVD private zones from the client network. Without all three, users cannot retrieve their feed at all.
  • For anyone to see anything: at least one application group, an association joining it to this workspace, and a role assignment on that group.

📁 Module Structure

terraform-azurerm-virtual-desktop-workspace/
├── providers.tf     # required_version >= 1.12.0; azurerm ~> 4.0; no provider block
├── variables.tf     # 8 inputs, 7 validations, deeply-typed with the schema in the descriptions
├── main.tf          # one keystone `this`; dynamic timeouts
├── outputs.tf       # 25 outputs: id first, then identity, then the posture facts
├── README.md        # this file
├── SCOPE.md         # the cross-module contract
├── LICENSE          # MIT
└── .gitignore

⚙️ Quick Start

provider "azurerm" {
  features {}
}

module "avd_workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"

  name                = "ws-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  friendly_name = "Production Desktops"

  # This module defaults public access to FALSE. Set it true unless AVD
  # Private Link is genuinely in place -- see example 3.
  public_network_access_enabled = true
}

⚠️ The empty call is deliberately the private one. Read private_link_is_required_for_the_feed before accepting it.

ℹ️ The caller configures the provider, its authentication, and the mandatory features {} block.


🔌 Cross-Module Contract

Consumes

Input Type Source module
resource_group_name string terraform-azurerm-resource-group → name
location string terraform-azurerm-resource-group → location
name, friendly_name, description, public_network_access_enabled, tags, timeouts caller

Emits

Output Consumed by
id the workspace-to-application-group association, private endpoints, diagnostics
name, resource_group_name, location compositions
feed_is_publicly_reachable, private_link_is_required_for_the_feed security and architecture review
friendly_name_was_supplied, description_was_supplied drift review
the posture constants human readers

📚 Example Library

1 · The smallest real call
module "avd_workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"

  name                = "ws-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location
}

⚠️ This applies cleanly and shows nobody anything. A workspace is an empty feed until an application group is associated with it, and this call also leaves public access disabled.

2 · The ordinary deployment — a public feed
module "avd_workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"

  name                = "ws-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  friendly_name                 = "Production Desktops"
  description                   = "Finance and operations desktops."
  public_network_access_enabled = true
}

output "posture" {
  value = module.avd_workspace.feed_is_publicly_reachable # true
}

💡 This is what Azure Virtual Desktop was designed for: users connect from anywhere over the internet, authenticated by Entra ID. The module makes you type it so that it reads as a decision.

3 · The private feed, and what it actually requires
module "avd_workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"

  name                = "ws-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  friendly_name = "Production Desktops"
  # public_network_access_enabled defaults to false
}

module "avd_feed_endpoint" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-private-endpoint.git?ref=v1.0.0"

  name                = "pe-avd-feed"
  resource_group_name = module.rg.name
  location            = module.rg.location
  subnet_id           = var.private_endpoint_subnet_id # from the virtual-network module

  private_service_connection = {
    name                           = "avd-feed"
    private_connection_resource_id = module.avd_workspace.id
    subresource_names              = ["feed"]
    is_manual_connection           = false
  }
}

🔴 A private endpoint on the workspace covers the feed only. Connections to the desktops themselves go through a private endpoint on the host pool, and both need DNS that resolves the AVD private zones from the client network. Miss any of it and users see nothing at all — private_link_is_required_for_the_feed is the module saying so.

4 · The display name that gets cleared
# Applied once, with a display name:
module "avd_workspace" {
  source        = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"
  name          = "ws-prod"
  resource_group_name = module.rg.name
  location      = module.rg.location
  friendly_name = "Production Desktops"
}

# Applied again with the argument removed -- this does NOT leave it alone.
# The provider sends friendly_name on every write, so it is set to "".

🔴 Omission is an instruction here, not an absence. It matters most when adopting a workspace by import: applying without these arguments clears whatever the portal set. friendly_name_was_supplied reports which way round you are.

5 · The name rule, exactly
name = "ws-prod"          # fine
name = "ws_prod_"         # fine -- a TRAILING UNDERSCORE is legal
name = "ws.prod-eastus_1" # fine -- dots, dashes and underscores inside
name = "ws1"              # fine -- three characters is the floor

name = "ws"               # rejected -- shorter than 3
name = "ws prod"          # rejected -- no spaces
name = "_wsprod"          # rejected -- must begin with a letter or digit
name = "wsprod-"          # rejected -- a trailing dash is not a word character
name = "wsprod."          # rejected -- nor is a trailing dot

⚠️ The asymmetry is the provider's, not this module's: the trailing check is a word-character test, which accepts an underscore. The application group's name rule is identical, character for character.

6 · Publishing an application group to this workspace
resource "azurerm_virtual_desktop_workspace_application_group_association" "desktop" {
  workspace_id         = module.avd_workspace.id
  application_group_id = module.avd_desktop_group.id
}

💡 This is the step that makes the feed non-empty, and it is deliberately not part of this module: a workspace publishes many groups, a group can move between workspaces, and folding the join in would make destroying a workspace take its groups' membership with it.

7 · Several workspaces from one map
locals {
  workspaces = {
    finance = { friendly_name = "Finance Desktops", description = "Finance line-of-business apps." }
    ops     = { friendly_name = "Operations Desktops", description = "Warehouse and logistics." }
  }
}

module "avd_workspace" {
  source   = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"
  for_each = local.workspaces

  name                = "ws-${each.key}"
  resource_group_name = module.rg.name
  location            = module.rg.location

  friendly_name                 = each.value.friendly_name
  description                   = each.value.description
  public_network_access_enabled = true
}

ℹ️ Keying on a stable identifier rather than an index keeps for_each keys stable when a workspace is added or removed.

8 · The region confusion, stated plainly
module "avd_workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"

  name                = "ws-prod"
  resource_group_name = module.rg.name
  location            = "eastus" # where the workspace METADATA lives
  # ...session hosts may be in westus2, centralus, or anywhere else.
}

⚠️ This location decides where the workspace's own service objects are stored. Sessions run on session hosts, which are virtual machines in whatever region they were built in — commonly and legitimately a different one. It is a frequent source of confusion in cost and data-residency reviews.

9 · Reading the posture back
output "avd_feed" {
  value = {
    id            = module.avd_workspace.id
    public        = module.avd_workspace.feed_is_publicly_reachable
    needs_private = module.avd_workspace.private_link_is_required_for_the_feed
    display_name  = module.avd_workspace.friendly_name
    named         = module.avd_workspace.friendly_name_was_supplied
  }
}

🔒 The pair worth reading together is public and needs_private: exactly one of them is true, and the second is the one that names a dependency this module cannot create.

10 · Tags, and what they do not cover
module "avd_workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"

  name                = "ws-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  tags = { env = "prod", owner = "eus-platform", cost_centre = "4412" }
}

ℹ️ These reach the workspace record and nothing else. The application groups, the host pools and the session hosts are separate ARM resources with their own tags, so a policy evaluating only this record says nothing about them.

11 · Timeouts
module "avd_workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"

  name                = "ws-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  timeouts = {
    create = "30m"
    read   = "5m"
    update = "30m"
    delete = "30m"
  }
}

ℹ️ All four are genuinely honoured — worth stating, because resources elsewhere in this library accept a timeout they then ignore, take the create deadline on an update, or have no update operation at all. The hour-long defaults are generous for what this does: the create is a single synchronous call. A misspelled key is discarded silently.

12 · 🏗️ End-to-end composition
provider "azurerm" {
  features {}
}

module "rg" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-resource-group.git?ref=v1.0.0"

  name     = "rg-avd-eastus"
  location = "eastus"
}

module "avd_host_pool" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-host-pool.git?ref=v1.0.0"

  name                = "hp-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  type               = "Pooled"
  load_balancer_type = "BreadthFirst"

  # Must agree with the application group's `type` -- and note the spelling.
  preferred_app_group_type = "Desktop"
}

module "avd_desktop_group" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-application-group.git?ref=v1.0.0"

  name                = "ag-desktop"
  resource_group_name = module.rg.name
  location            = module.rg.location

  type         = "Desktop"
  host_pool_id = module.avd_host_pool.id

  friendly_name                = "Production Desktop"
  default_desktop_display_name = "Production Desktop"
}

module "avd_workspace" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-virtual-desktop-workspace.git?ref=v1.0.0"

  name                = "ws-prod"
  resource_group_name = module.rg.name
  location            = module.rg.location

  friendly_name                 = "Production Desktops"
  description                   = "Finance and operations desktops."
  public_network_access_enabled = true

  tags = { env = "prod" }
}

# The join that makes the feed non-empty.
resource "azurerm_virtual_desktop_workspace_application_group_association" "desktop" {
  workspace_id         = module.avd_workspace.id
  application_group_id = module.avd_desktop_group.id
}

# And the grant that lets a user see it. Scoped to the GROUP, not the workspace.
module "avd_users" {
  source = "git::https://github.com/microsoftexpert/terraform-azurerm-role-assignments.git?ref=v1.0.0"

  scope = module.avd_desktop_group.id

  role_assignments = {
    desktop_users = {
      principal_id         = var.avd_users_group_object_id
      role_definition_name = "Desktop Virtualization User"
      principal_type       = "Group"
      description          = "Lets the finance team sign in to the production desktop."
    }
  }
}

output "avd" {
  value = {
    workspace     = module.avd_workspace.id
    public_feed   = module.avd_workspace.feed_is_publicly_reachable
    group         = module.avd_desktop_group.id
    pool_type_set = module.avd_desktop_group.matching_host_pool_preferred_app_group_type
  }
}

🔒 Every reference is a real output of a real sibling, and the shape is the point: four things must exist before a person can launch a desktop — a host pool, an application group, a workspace, an association — and then a role assignment on the group. This module is one of them.


📥 Inputs

Identity — name (3–64, force-new), resource_group_name, location (both force-new) Display — friendly_name, description (both always sent) Access — public_network_access_enabled (inverted to false) Tail — tags, timeouts

Full input schemas
variable "name" { type = string }
# 3-64 characters; letters, digits, dots, dashes, underscores.
# Must BEGIN with a letter or digit and END with a letter, digit or UNDERSCORE.
# FORCE-NEW.

variable "resource_group_name" { type = string } # FORCE-NEW
variable "location"            { type = string } # FORCE-NEW, normalised by the provider

variable "friendly_name" { type = string, default = null } # 1-64;  ALWAYS SENT
variable "description"   { type = string, default = null } # 1-512; ALWAYS SENT

variable "public_network_access_enabled" {
  type    = bool
  default = false # INVERTS the provider's `true` -- see the Design Principles table
}

variable "tags"     { type = map(string), default = {} }
variable "timeouts" { type = object({ create, read, update, delete }), default = null }

🧾 Outputs

Output Description Notes
id Resource ID of the workspace. Consumed by the association.
name The Azure resource name. Not what users see.
resource_group_name, location Identity. Location is normalised.
friendly_name What users see. Empty when none was configured.
description Free text.
public_network_access_enabled As the provider reports it.
tags On this record only.
feed_is_publicly_reachable Derived. Conditional.
private_link_is_required_for_the_feed The cost of this module's default. Conditional.
friendly_name_was_supplied Because omission is not absence. Conditional.
description_was_supplied Same. Conditional.
this_workspace_publishes_nothing_by_itself Constant true. The first thing to understand.
the_public_access_default_is_inverted_from_the_provider Constant true. Stated so it never surprises.
friendly_name_and_description_are_always_sent Constant true. Changes what omission means.
the_workspace_region_is_metadata_not_where_sessions_run Constant true.
the_host_pool_expresses_public_access_as_a_four_valued_string Constant true. Intra-family asymmetry.
the_name_may_end_in_an_underscore_but_not_a_dot_or_dash Constant true.
an_import_guard_exists_on_this_resource Constant true. Import, do not apply over.
this_resource_carries_a_state_upgrader Constant true.
all_four_timeouts_are_honoured_here Constant true.
force_new_fields, fields_that_can_change_after_creation, fields_azure_returns_on_read Lifecycle summary.
no_secret_is_accepted_or_emitted_by_this_module Constant true.

🔒 No output is sensitive, because this resource carries no credential at all.


🧠 Architecture Notes

A workspace is a feed, and a feed can be empty. This record creates no compute, publishes nothing and authorises nobody. Four things must exist before a person can launch a desktop — a host pool, an application group, a workspace and an association joining the last two — and then a role assignment scoped to the group. A clean apply here is one of five steps, and the module says so rather than letting a green plan imply otherwise.

The public-access default is inverted, and the divergence is stated twice. The provider defaults public_network_access_enabled to true; this module defaults it to false, per this suite's rule that a boolean gating exposure takes the closed value, and consistently with the already-authored host-pool module in this family. What makes the divergence worth arguing about is its cost: with public access off, the feed is reachable only through Azure Virtual Desktop Private Link, which needs a private endpoint on this workspace, another on the host pool, and DNS for the AVD private zones. None of that is created here. So the module emits both private_link_is_required_for_the_feed and a constant naming the divergence itself, and the ordinary internet-facing deployment is one typed argument away.

Omitting a display name is an instruction. The provider passes friendly_name and description on every create and update whether or not they are configured, so leaving one out sends an empty string. That is harmless on a greenfield apply and destructive on an adopted workspace: import a workspace the portal named, apply without these arguments, and the name is gone. friendly_name_was_supplied exists so a reviewer can tell which situation they are in.

Two spellings of one idea, in one family. This resource controls public access with a boolean; the host pool controls it with a four-valued string. They must be reasoned about together — a workspace open to clients in front of a host pool that is not leaves a half-open path that neither resource reports — and the asymmetry is exactly the kind that survives a spot-check, because both fields look right in isolation.

The name rule is mirrored, not tidied. 3 to 64 characters, beginning with a letter or digit and ending with a letter, digit or underscore. The trailing allowance for an underscore but not a dot or dash looks like an oversight and is the provider's actual rule; a neater version would reject legal input, and a validation {} failure blocks terraform destroy as well as apply.


🧱 Design Principles

Concern Secure default (empty call) Opt-out (caller must type it)
Feed exposure public_network_access_enabled = false — inverted from the provider's true, and matching the host-pool module in this family. Set it true for the ordinary internet-facing deployment.
The cost of that default Reported, not hidden. private_link_is_required_for_the_feed names the three things this module cannot create. —
Display strings Refused when empty, because the provider treats "" as an invalid length rather than an absent value. Omit the argument instead.
Names Mirrored exactly, including the provider's underscore asymmetry. —
Secrets None accepted, none emitted. —
Publishing and authorising Neither is done here. The association and the role assignment are separate decisions with separate lifecycles. —

🔒 A validation {} failure blocks terraform destroy as well as apply, which is why this module refuses only what the provider refuses and reports everything else.


🚀 Runbook

terraform init -backend=false
terraform validate
terraform fmt -check

Pin the source at a tag — ?ref=v1.0.0 — never a branch. This module is authored and verified plan-only; a human applies from CI.


🧪 Testing

What validate and fmt cover, with no credentials:

  • All 7 input validations, including the three-part name rule the published schema does not expose and the empty-string cases that a compact()-based guard would silently skip.
  • The length limits on friendly_name and description.
  • The resource-group-name-not-an-ID check.
  • HCL syntax and formatting.

What only plan or apply reaches:

  • Whether Microsoft.DesktopVirtualization is registered in the subscription.
  • Whether the workspace name is already taken.
  • Whether the Private Link path exists, when public access is disabled.

🔴 Nothing offline can tell you a user will see a desktop. That depends on an application group, an association and a role assignment, none of which this module creates.


💬 Example Output

Outputs:

avd_feed = {
  "display_name"  = "Production Desktops"
  "id"            = "/subscriptions/8f3a2b1c-4d5e-6f70-8192-a3b4c5d6e7f8/resourceGroups/rg-avd-eastus/providers/Microsoft.DesktopVirtualization/workspaces/ws-prod"
  "named"         = true
  "needs_private" = false
  "public"        = true
}
description_was_supplied = true
location                 = "eastus"

🔍 Troubleshooting

Symptom Cause Fix
name must be between 3 and 64 characters… Shorter than three characters, or longer than 64. Rename. It is force-new.
name must BEGIN with a letter or digit and END with a letter, digit or UNDERSCORE A leading dash or underscore, or a trailing dot or dash. Note the asymmetry: a trailing underscore is legal, a trailing dash is not.
friendly_name must be between 1 and 64 characters when set It was set to an empty string. Omit the argument. The provider treats "" as an invalid length, not an absent value.
Users see no desktops although the workspace exists The workspace publishes nothing until an application group is associated with it, and authorises nobody. Create the association resource, and a role assignment scoped to the application group.
Users cannot reach the feed at all Public access is disabled — this module's default — and the Private Link path is incomplete. Either set public_network_access_enabled = true, or add private endpoints on both the workspace and the host pool plus the AVD private DNS zones.
The display name disappeared after an apply The provider sends friendly_name on every write, so omitting it clears the field. Set the argument. Check friendly_name_was_supplied.
An imported workspace reads back as public although the portal shows otherwise The read treats any absent or non-Enabled value as public. Set the argument explicitly and apply.
A change to location or resource_group_name plans a replacement Both are force-new, as is name. Expected — read force_new_fields.
Apply fails with an already-exists error An import guard exists and the workspace was created in the portal. terraform import it rather than applying over it.
A timeouts key has no effect It was misspelled, and Terraform's object-type conversion discards undeclared keys silently. Check the spelling against create / read / update / delete.

🔗 Related Docs


💙 "Infrastructure as Code should be standardized, consistent, and secure."