THIS CODE-SAMPLE IS PROVIDED "AS IS" WITHOUT WARRANTY OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE IMPLIED WARRANTIES OF MERCHANTABILITY AND/OR FITNESS FOR A PARTICULAR PURPOSE. This sample is not supported under any Microsoft standard support program or service. The script is provided AS IS without warranty of any kind. Microsoft further disclaims all implied warranties including, without limitation, any implied warranties of merchantability or of fitness for a particular purpose. The entire risk arising out of the use or performance of the sample and documentation remains with you. In no event shall Microsoft, its authors, or anyone else involved in the creation, production, or delivery of the script be liable for any damages whatsoever (including, without limitation, damages for loss of business profits, business interruption, loss of business information, or other pecuniary loss) arising out of the use of or inability to use the sample or documentation, even if Microsoft has been advised of the possibility of such damages, rising out of the use of or inability to use the sample script, even if Microsoft has been advised of the possibility of such damages.
Azure Developer CLI (azd) template that deploys a monitored Azure Virtual Desktop environment across two resource groups.
New here? Start with QUICKSTART.md — deploy to a connected user in six steps.
rg-<env>-monitoring— Log Analytics workspace + Data Collection Rule. Subscription Activity Log is streamed here, plus resource logs from every resource below. Also deploys an AVD Insights workbook (Microsoft.Insights/workbooks,AVD Insights - <env>) sourced from the same workspace, with sections for connections, errors/checkpoints, agent health, management activities, and session host CPU/memory/input-delay/event log data.rg-<env>-avd- VNet
172.16.0.0/16(RFC1918-compliant private space)snet-avd(172.16.0.0/23, 507 usable IPs — sized for 500 AVD hosts) with a NAT Gateway attached for outbound internet accessAzureBastionSubnet(172.16.2.0/26)
- Standard Azure Bastion host for remote administration (no public RDP/SSH)
- AVD Host Pool (Personal, Direct assignment — dedicated, persistent desktops), Desktop Application Group, and Workspace
- 10 session host VMs:
Standard_B2s(B-series, 2 vCPU / 4 GB RAM)- 128 GB Standard_LRS OS disk
- Microsoft Entra ID joined only (no on-prem AD / domain controller — "cloud only")
- Registered into the host pool automatically via the AVD DSC extension
- Azure Monitor Agent + DCR association, sending Windows Event Logs and performance counters to the same Log Analytics workspace
- VNet
All resource groups are tagged azd-env-name so azd down can find and remove
everything.
- Azure Developer CLI installed
- Azure CLI and PowerShell 7 (
pwsh) — used by theazdhooks inazure.yaml - An Azure subscription with quota for 10x
Standard_B2sVMs and AVD - An Entra role that can delete device objects (Cloud Device Administrator, Intune Administrator, Windows 365 Administrator or Global Administrator) so the hooks can clean up session host registrations — see Notes
- An Entra role that can manage groups (Groups Administrator) so the hooks can
create and delete the
sg-avd-<env>access group - An Entra role that can update application configuration (Application Administrator, Cloud Application Administrator or Global Administrator) so the hooks can enable Microsoft Entra ID authentication for RDP — without it the desktop is visible but no one can sign in, see Notes
azd auth login
azd init # if not already initialized in this folder
azd env set AVD_ADMIN_PASSWORD '<a-strong-password>'
azd upAVD_ADMIN_PASSWORD is required (min 12 chars) and is used as the local
administrator password on each session host VM.
Number of session hosts (1–500): since this parameter has no default,
azd up will automatically prompt you for it (validated against the 1-500
range declared in main.bicep). To skip the prompt, set it ahead of time:
azd env set AVD_SESSION_HOST_COUNT 25azd up provisions the infrastructure but nobody can use a desktop until you
grant access and assign a session host.
➡️ QUICKSTART.md — the six-step happy path. ➡️ docs/post-deployment.md — full reference, including the Azure portal equivalents and a troubleshooting table.
The short version:
# 1. Add the user to the access group the deployment manages
az ad group member add --group "sg-avd-$(azd env get-value AZURE_ENV_NAME)" `
--member-id (az ad user show --id user@contoso.com --query id -o tsv)
# 2. Grant them the AVD roles (also makes them selectable in the portal's Assign picker)
azd hooks run postprovision
# 3. Pin them to a session host, in the portal or via ARM - see the guideazd down --purgeThe predown hook removes the session hosts' Microsoft Entra ID device objects
first, and the postdown hook deletes the sg-avd-<env> access group. Skipping
them (for example by tearing down the resource groups by hand) leaves stale
device objects behind that will break the Entra join on the next azd up — see
Notes.
-
Session host registration token expires 4 hours after deployment starts; re-run
azd up(or a targetedaz deploymentupdate) if hosts need to re-register after that window. -
Granting desktop access — add the user to
sg-avd-<env>. The deployment creates and manages a security group for you, so membership is the only thing you normally touch. Full walkthrough in docs/post-deployment.md.az ad group member add --group sg-avd-<env> --member-id (az ad user show --id user@contoso.com --query id -o tsv) azd hooks run postprovision # grant the new member the AVD roles
Access requires two roles; publishing the desktop alone is not enough:
- Desktop Virtualization User on the Desktop Application Group — lets the user see and launch the desktop in the AVD client.
- Virtual Machine User Login on
rg-<env>-avd— lets the user actually sign in to the Microsoft Entra ID-joined session hosts.
scripts/avd-access-group.ps1manages this and runs from three hooks:Hook Action Purpose preprovision-Action EnsureCreates sg-avd-<env>if missing and publishes its object ID asAVD_USER_GROUP_IDS, which the deployment then grants both roles.postprovision-Action SyncGives each group member a direct assignment of both roles. postdown-Action RemoveDeletes the group, since it belongs to this environment. ⚠️ Why members also need direct assignments. For a personal host pool the portal's session host "Assign" picker only enumerates users holding a direct Desktop Virtualization User assignment on the application group — it does not expand security groups. Without theSyncstep, group members get working access but never appear as assignable candidates, and the Assign dialog looks empty. This is why adding someone to the group must be followed byazd hooks run postprovision.Add
-Pruneto also revoke users who have been removed from the group, making the group the sole source of truth:./scripts/avd-access-group.ps1 -Action Sync -Prune
azd downdeletes the group, so its membership does not survive a teardown. UseAVD_ACCESS_GROUP_NAMEto point at a differently-named group.To use your own pre-existing principals instead, set either of these before provisioning (both accept comma-separated lists, and both roles are created for every principal listed):
azd env set AVD_USER_GROUP_IDS '<group-object-id>[,<group-object-id>...]' azd env set AVD_USER_IDS '<user-object-id>[,<user-object-id>...]'
AVD_USER_GROUP_IDSis overwritten by thepreprovisionhook. Groups must be security-enabled, and users listed inAVD_USER_IDSare the ones that appear in the Assign picker. (AVD_USER_PRINCIPAL_ID/AVD_USER_PRINCIPAL_TYPEstill work as a single-principal shorthand.)These settings live in
.azure/<env>/.env, which is per-environment. Creating a new azd environment starts from an empty.env, so any principals you set by hand must be set again for that environment.Subscription Owner / Global Administrator does not grant desktop access. Owner's
*permission coversactionsonly, while both roles above aredataActions— which*never matches. An Owner who hasn't been granted these roles gets an empty feed in the AVD client and cannot sign in to a session host.The principal must be a security group. Microsoft 365 groups (
securityEnabled: false, i.e. mail-enabled collaboration groups such as a default "All Company") are rejected by Azure withGroupTypeNotSupported: Only security-enabled groups can be used in role assignments. Create a security group instead:az ad group create --display-name sg-avd-users --mail-nickname sg-avd-users
-
Session hosts are Microsoft Entra ID joined only — there is no on-premises AD or domain controller. Entra ID device objects are directory objects, not ARM resources, so
azd downdoes not delete them. Session host names are deterministic (avd<uniqueString(subscriptionId, envName)><index>), so a laterazd uprecreates VMs with identical computer names and the leftover device objects still own those hostnames. The join is then rejected with:0x801c0083 / error_hostname_duplicate "Another object with the same value for property hostnames already exists."This failure is silent from ARM's point of view: the
AADLoginForWindowsextension still reports "Provisioning succeeded" (it only reports handler installation, not the join result), butdsregcmd /statusshowsAzureAdJoined : NO, the AVD agent'sDomainJoinedCheckandDomainTrustCheckfail, and every session host reportsUnavailable.scripts/cleanup-entra-devices.ps1prevents this and is wired intoazure.yamlas two hooks:Hook Invocation Purpose predown-AllDeletes the device objects while the VMs still exist, so nothing is orphaned by the teardown. preprovision(default) Safety net. Deletes only devices that are provably stale — no matching VM, or a device created before the VM that currently holds the name. Devices belonging to healthy current VMs are left alone, so it is safe on a re-provision. Deleting device objects requires an Entra role such as Cloud Device Administrator or Global Administrator. Both hooks use
continueOnError: true, so insufficient permissions will not blockazd.To repair hosts that are already stuck in this state, delete the stale devices and force a re-join on each VM:
./scripts/cleanup-entra-devices.ps1 az vm run-command invoke -g rg-<env>-avd -n <vm> --command-id RunPowerShellScript ` --scripts "dsregcmd /leave" "Start-ScheduledTask -TaskPath '\Microsoft\Windows\Workplace Join\' -TaskName 'Automatic-Device-Join'" az vm restart -g rg-<env>-avd -n <vm>
-
Because the session hosts are Entra ID joined, the host pool sets the
enablerdsaadauth:i:1custom RDP property, so clients authenticate with a Microsoft Entra ID token. This gives single sign-on and is what lets clients that are not Entra joined to the same tenant — including the web client and macOS/iOS/Android — connect at all.Microsoft only issues that token once the tenant opts in, by setting
isRemoteDesktopProtocolEnabledon the Windows Cloud Login service principal. That flag lives on a directory object, so Bicep cannot set it;scripts/enable-rdp-sso.ps1does, wired intoazure.yamlas two hooks:Hook Invocation Purpose postprovision-Action EnableOpts the tenant in to Entra ID RDP authentication, then registers the session hosts in a sg-avd-<env>-hostsdevice group so users are not prompted to allow each new connection.postdown-Action RemoveDeletes that device group. The tenant-wide flag stays on, since other host pools may rely on it. ⚠️ If the desktop appears but sign-in fails with "the credentials did not work", this opt-in is usually missing. Check it with:$sp = az ad sp show --id 270efc09-cd0d-444b-a71f-39af4910ec45 --query id -o tsv az rest --method get --url "https://graph.microsoft.com/beta/servicePrincipals/$sp/remoteDesktopSecurityConfiguration"
If
isRemoteDesktopProtocolEnabledisfalse, run./scripts/enable-rdp-sso.ps1. Setting it requires an Entra role such as Application Administrator, Cloud Application Administrator or Global Administrator.enablerdsaadauthreplaces the oldertargetisaadjoined:i:1, and the two are mutually exclusive — never set both. The old property restricted sign-in to a username and password prompt, which fails outright for any account subject to multifactor authentication or Conditional Access. Override the full string with thecustomRdpPropertyparameter. -
The host pool uses Direct personal desktop assignment so an admin can pin a specific user to a specific session host. Azure disables the portal's "Assign" button when a personal host pool uses
Automatic, which instead claims the first free host on initial sign-in. BecauseDirectdoes not auto-claim, every user must be explicitly assigned to a session host before they can connect:az rest --method patch ` --url "https://management.azure.com/subscriptions/<sub>/resourceGroups/rg-<env>-avd/providers/Microsoft.DesktopVirtualization/hostPools/hp-<env>/sessionHosts/<vm>?api-version=2024-04-03" ` --headers "Content-Type=application/json" ` --body '{\"properties\":{\"assignedUser\":\"[email protected]\"}}'
Override with:
azd env set AVD_ASSIGNMENT_TYPE Automatic
-
Session host image is
MicrosoftWindowsDesktop:windows-11:win11-23h2-avd:latest. -
The AVD Insights workbook deployed here is a custom, functionally-equivalent workbook built from the documented AVD Log Analytics tables (
WVDConnections,WVDErrors,WVDCheckpoints,WVDAgentHealthStatus,WVDManagementActivities,WVDFeeds,Perf,Event) — Microsoft's official gallery "AVD Insights" workbook content is proprietary and isn't published as reusable JSON, so it can't be embedded verbatim in IaC. Anyone viewing it needs Desktop Virtualization Reader (on the AVD resource group) and Log Analytics Reader (on the workspace) RBAC roles, per the AVD Insights prerequisites.