Home of all things Azure and AI
Sponsored by:
Productized Solutions Built Specifically for Partners
Join the deployNatGateway open forum
by Matthew
A practical guide for IT Pros packaging AVD as an Azure Marketplace managed application
Frametype Solutions LLC β’ March 2026
The previous post in this series covered what Entra Kerberos is and why it is the right authentication model for FSLogix profile storage in cloud-only AVD deployments. That post ended with a list of registry keys that must be present on every session host for Entra Kerberos and FSLogix to work:
HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\Kerberos\Parameters\CloudKerberosTicketRetrievalEnabled = 1HKLM:\Software\Policies\Microsoft\AzureADAccount\LoadCredKeyFromProfile = 1HKLM:\SOFTWARE\FSLogix\Profiles\Enabled = 1HKLM:\SOFTWARE\FSLogix\Profiles\VHDLocations = \\<storageaccount>.file.core.windows.net\profilesHKLM:\SOFTWARE\FSLogix\Profiles\VolumeType = VHDXWhat that post did not cover is how to write those keys automatically as part of an ARM template deployment β without any manual intervention after the VMs are provisioned. That is what this post is about.
Getting this right turned out to be harder than expected. The obvious approach β using a Custom Script Extension β has a class of failure modes that are not well documented and that produce no useful error output. This post documents what those failure modes are, why they occur, and the pattern that actually works reliably.
The Custom Script Extension (CSE) is the standard ARM mechanism for running scripts on a Windows VM after provisioning. It accepts a script file and a command to execute, downloads the script to the VM, and runs it. For simple post-provisioning tasks it works well.
For this use case the intent was to:
Configure-FSLogix.ps1 as a base64 string and embed it directly in the template as a variable β eliminating any dependency on an external script URL, which is a hard blocker for Azure Marketplace certificationThat approach fails in two distinct ways, and the failure modes are easy to confuse with each other.
The Custom Script Extension has two variants: one for Windows (Microsoft.Compute.CustomScriptExtension) and one for Linux (Microsoft.Azure.Extensions.CustomScript). They share a similar interface but differ in what protectedSettings supports.
The Linux CSE supports a protectedSettings.script field that accepts a base64-encoded script and executes it directly β no download, no file URL required. This is exactly what we needed.
The Windows CSE does not support protectedSettings.script. It accepts the field without error β the ARM deployment succeeds, the extension reports as provisioned β but the field is silently ignored. No script is written to disk. No script is executed. The only indication that anything went wrong is that the effects of the script are absent.
This behavior is not documented clearly in a single place. The Microsoft Learn documentation for the Windows CSE and the Linux CSE are separate pages and the protectedSettings.script field does not appear in the Windows documentation at all. If you are working from Linux CSE examples or from community posts that do not specify the OS variant, it is easy to assume the field works on both.
The diagnostic: Run the following on the VM after deployment and check whether the script file exists:
Test-Path "C:/configure-fslogix.ps1"
If it returns False, the script was never written. The CSE did not execute.
Once protectedSettings.script was removed and the full decode-write-execute logic was moved into commandToExecute, a second problem appeared. The CSE handler on the VM was not executing the script at all on fresh VMs in a fresh resource group.
The CSE handler tracks sequence numbers internally to implement idempotency β it will not re-execute a configuration it has already processed. Each time the ARM deployment engine invokes the CSE handler during a deployment, the handler increments its internal sequence counter. The enable command that actually runs the script checks whether the current sequence number is greater than the last processed sequence number. If it is not, the handler exits without executing.
The handler log entry that indicates this is:
WARN: Current sequence number, 0, is not greater than the sequence number
of the most recently executed configuration. Exiting...
In testing this appeared even on VMs that had just been created in a brand new resource group β which rules out state carrying over from a previous deployment. The cause is that during a deployment with multiple concurrent extensions (AADLoginForWindows, AzureMonitorWindowsAgent, DSC, and CSE all running on the same VM), the ARM deployment engine invokes the CSE handler multiple times while waiting for other extensions to complete. Each invocation increments the sequence counter. By the time all dependencies are resolved and the final enable call arrives, the handler considers the sequence already processed and skips execution.
The CSE handler log for this scenario shows multiple invocation entries within a short window β in one test deployment, nine CommandExecution_*.log files were created within thirteen minutes on a single VM before the final sequence guard fired.
forceUpdateTag on the CSE resource is supposed to address this by generating a new sequence number on each deployment, but it does not prevent the within-deployment sequence collision caused by repeated handler invocations during dependency resolution.
The diagnostic: Check the CSE handler log on the VM:
Get-Content "C:\WindowsAzure\Logs\Plugins\Microsoft.Compute.CustomScriptExtension\1.10.20\CustomScriptHandler.log" | Select-Object -Last 50
If you see the sequence number warning, the script was never executed regardless of what commandToExecute contains.
The reliable pattern is to move the FSLogix registry configuration out of the CSE entirely and into a Microsoft.Resources/deploymentScripts resource that uses Invoke-AzVMRunCommand to push the script into each VM from outside.
This approach has several advantages over CSE:
dependsOn chain that ensures the VM and all its extensions are fully provisioned before the script is pushed.Invoke-AzVMRunCommand. The VM never needs to download anything from an external URL.The script running inside the container:
Invoke-AzVMRunCommand to execute it on the target VM, passing the FSLogix share UNC path as a parameter{
"type": "Microsoft.Resources/deploymentScripts",
"apiVersion": "2023-08-01",
"name": "[concat('script-fslogix-', padLeft(string(copyIndex(1)), 2, '0'))]",
"location": "[parameters('location')]",
"kind": "AzurePowerShell",
"identity": {
"type": "UserAssigned",
"userAssignedIdentities": {
"[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('scriptIdentityName'))]": {}
}
},
"copy": {
"name": "fslogixScriptLoop",
"count": "[parameters('vmCount')]"
},
"dependsOn": [
"[resourceId('Microsoft.Compute/virtualMachines/extensions', concat(variables('vmNamePrefix'), '-', padLeft(string(copyIndex(1)), 2, '0')), 'Microsoft.PowerShell.DSC')]",
"[resourceId('Microsoft.Compute/virtualMachines/extensions', concat(variables('vmNamePrefix'), '-', padLeft(string(copyIndex(1)), 2, '0')), 'AADLoginForWindows')]",
"[resourceId('Microsoft.Authorization/roleAssignments', guid(resourceGroup().id, variables('scriptIdentityName'), variables('roleVmContributor')))]"
],
"properties": {
"azPowerShellVersion": "9.0",
"retentionInterval": "PT1H",
"timeout": "PT15M",
"forceUpdateTag": "[parameters('forceUpdateTag')]",
"environmentVariables": [
{ "name": "ResourceGroupName", "value": "[resourceGroup().name]" },
{ "name": "VmName", "value": "[concat(variables('vmNamePrefix'), '-', padLeft(string(copyIndex(1)), 2, '0'))]" },
{ "name": "ShareUNC", "value": "[variables('fslogixProfileUnc')]" },
{ "name": "ScriptB64", "value": "[variables('fslogixScriptB64')]" }
],
"scriptContent": "$b = [Convert]::FromBase64String($env:ScriptB64); $tmp = [IO.Path]::GetTempFileName() + '.ps1'; [IO.File]::WriteAllBytes($tmp, $b); $result = Invoke-AzVMRunCommand -ResourceGroupName $env:ResourceGroupName -VMName $env:VmName -CommandId RunPowerShellScript -ScriptPath $tmp -Parameter @{ShareUNC=$env:ShareUNC} -ErrorAction Stop; Remove-Item $tmp -Force; $out = $result.Value | ForEach-Object { $_.Message }; Write-Output $out; if ($result.Status -eq 'Failed') { throw 'FSLogix configuration failed on ' + $env:VmName }"
}
}
The script executed on the VM writes all required registry keys for FSLogix and Entra Kerberos in a single pass. It creates any missing registry paths, uses -Force on all New-ItemProperty calls so it is safe to run multiple times, and writes a transcript log to C:\WindowsAzure\Logs\FSLogix\ for post-deployment verification.
param(
[Parameter(Mandatory=$true)][string]$ShareUNC
)
$ErrorActionPreference = "Stop"
$logDir = "C:\WindowsAzure\Logs\FSLogix"
New-Item -ItemType Directory -Force -Path $logDir | Out-Null
$logFile = Join-Path $logDir ("Configure-FSLogix_{0:yyyyMMdd_HHmmss}.log" -f (Get-Date))
Start-Transcript -Path $logFile -Append
try {
# Ensure required services are running for Entra Kerberos
foreach ($svc in @("WinHttpAutoProxySvc", "iphlpsvc")) {
$s = Get-Service -Name $svc -ErrorAction SilentlyContinue
if ($s -and $s.Status -ne "Running") {
Set-Service -Name $svc -StartupType Automatic -ErrorAction SilentlyContinue
Start-Service -Name $svc -ErrorAction SilentlyContinue
}
}
# Enable cloud Kerberos ticket retrieval
$kerbPath = "HKLM:\SYSTEM\CurrentControlSet\Control\Lsa\Kerberos\Parameters"
if (-not (Test-Path $kerbPath)) { New-Item -Path $kerbPath -Force | Out-Null }
New-ItemProperty -Path $kerbPath -Name "CloudKerberosTicketRetrievalEnabled" `
-PropertyType DWord -Value 1 -Force | Out-Null
# Required for FSLogix + Entra Kerberos credential key loading
$aadPath = "HKLM:\Software\Policies\Microsoft\AzureADAccount"
if (-not (Test-Path $aadPath)) { New-Item -Path $aadPath -Force | Out-Null }
New-ItemProperty -Path $aadPath -Name "LoadCredKeyFromProfile" `
-PropertyType DWord -Value 1 -Force | Out-Null
# FSLogix profile container configuration
$fsxProfiles = "HKLM:\SOFTWARE\FSLogix\Profiles"
if (-not (Test-Path $fsxProfiles)) { New-Item -Path $fsxProfiles -Force | Out-Null }
New-ItemProperty -Path $fsxProfiles -Name "Enabled" -PropertyType DWord -Value 1 -Force | Out-Null
New-ItemProperty -Path $fsxProfiles -Name "VHDLocations" -PropertyType MultiString -Value $ShareUNC -Force | Out-Null
New-ItemProperty -Path $fsxProfiles -Name "VolumeType" -PropertyType String -Value "VHDX" -Force | Out-Null
New-ItemProperty -Path $fsxProfiles -Name "DeleteLocalProfileWhenVHDShouldApply" `
-PropertyType DWord -Value 1 -Force | Out-Null
New-ItemProperty -Path $fsxProfiles -Name "FlipFlopProfileDirectoryName" `
-PropertyType DWord -Value 1 -Force | Out-Null
} catch {
Write-Error $_
throw
} finally {
Stop-Transcript | Out-Null
}
The deployment script uses Invoke-AzVMRunCommand, which requires the Virtual Machine Contributor role on the resource group. The same user-assigned managed identity used for the registration token script handles this β add the role assignment alongside the existing Desktop Virtualization Contributor assignment:
{
"type": "Microsoft.Authorization/roleAssignments",
"apiVersion": "2022-04-01",
"name": "[guid(resourceGroup().id, variables('scriptIdentityName'), variables('roleVmContributor'))]",
"dependsOn": [
"[resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('scriptIdentityName'))]"
],
"properties": {
"roleDefinitionId": "[variables('roleVmContributor')]",
"principalId": "[reference(resourceId('Microsoft.ManagedIdentity/userAssignedIdentities', variables('scriptIdentityName')), '2023-01-31').principalId]",
"principalType": "ServicePrincipal"
}
}
Where roleVmContributor is [subscriptionResourceId('Microsoft.Authorization/roleDefinitions', '9980e02c-c2be-4d73-94e8-173b1dc7cf3c')].
After deployment, verify the registry keys are present with a single Run Command query in the Azure Portal:
Get-ItemProperty "HKLM:\SOFTWARE\FSLogix\Profiles" -ErrorAction SilentlyContinue |
Select-Object Enabled, VHDLocations, VolumeType,
DeleteLocalProfileWhenVHDShouldApply, FlipFlopProfileDirectoryName
Expected output:
Enabled : 1
VHDLocations : {\\<storageaccount>.file.core.windows.net\profiles}
VolumeType : VHDX
DeleteLocalProfileWhenVHDShouldApply : 1
FlipFlopProfileDirectoryName : 1
The FSLogix transcript log at C:\WindowsAzure\Logs\FSLogix\Configure-FSLogix_*.log on the VM will also confirm the script executed and completed without errors.
The end-to-end validation is a user login via the AVD web client. After a successful login, check the Azure Files share in the portal β a <username> directory containing a .vhdx file should appear within a few seconds of the desktop loading.
If you find examples online that use protectedSettings.script to pass an inline base64 script to a CSE resource, check whether the example is for Linux or Windows. The field is a Linux CSE feature. On Windows it is silently ignored, the ARM deployment succeeds, and you will spend time debugging why the script effects are absent.
In ARM templates that deploy multiple VM extensions concurrently, the ARM engine may invoke the CSE handler multiple times during dependency resolution. Each invocation increments the internal sequence counter. By the time the actual enable command arrives, the handler may consider the sequence already processed and exit without running the script. This happens on fresh VMs with no prior deployment history and is not prevented by forceUpdateTag.
The only reliable mitigation is to avoid using CSE for post-provisioning configuration that must run exactly once after all other extensions complete. Use deploymentScript + Invoke-AzVMRunCommand instead β it has no sequence number state and a deterministic execution model.
Invoke-AzVMRunCommand is a synchronous call that blocks until the script completes on the VM. The output is returned in the result object, not streamed to the deployment script container in real time. For scripts that take longer than a few minutes, set the deploymentScript timeout appropriately β PT15M is sufficient for the FSLogix configuration script which completes in under ten seconds.
The deployment script runs in an Azure Container Instance that needs to reach the Azure management APIs to call Invoke-AzVMRunCommand. If your VNet has outbound internet access restricted (for example via an NSG or Azure Firewall without the required service tags), the container will fail to authenticate. The AVD session host VNet should have outbound access via a NAT Gateway for normal operation β the deployment script container uses a separate network context and is not affected by the session host VNet configuration.
The deploymentScript pattern solves FSLogix configuration inside the ARM template. But there is a class of AVD tasks that genuinely cannot run inside the template β the scaling plan host pool association being the most common example. These tasks require post-deployment scripts that run in the operatorβs local PowerShell session.
Those scripts have their own reliability problem worth documenting here.
The post-deployment script for scaling plan association uses Get-AzADServicePrincipal to look up the AVD service principal object ID before assigning RBAC roles. In some environments this cmdlet fails with a misleading error:
Your Azure credentials have not been set up or have expired, please run Connect-AzAccount
Method not found: 'Void Azure.Identity.Broker.SharedTokenCacheCredentialBrokerOptions..ctor(...)'
The credentials are valid β the same session successfully ran other Az cmdlets moments earlier. The real error is buried in the second line: a Method not found exception in Azure.Identity.Broker. This is a DLL conflict between the Az module version and the Azure Identity broker library. The specific cmdlets affected depend on which code path they hit internally β Get-AzADServicePrincipal and Get-AzWvdScalingPlan both hit the broken path in this environment, while other Az cmdlets do not.
The error message says βcredentials have not been set upβ which sends you down an authentication troubleshooting path that leads nowhere. The actual problem is a version conflict in the Az module dependency chain, not authentication. Running Connect-AzAccount again does not help. The -UseDeviceAuthentication flag does not help. The credentials are fine.
The Azure CLI (az) uses a completely separate authentication context and dependency stack. It is not affected by the Az PowerShell module broker issue. For post-deployment scripts that interact with AVD, Graph, and ARM resources, using az CLI calls throughout β rather than Az PowerShell cmdlets β produces scripts that work consistently regardless of the local Az module version.
The rewritten scaling plan association script uses az rest for all ARM API calls:
# Resolve AVD service principal β works where Get-AzADServicePrincipal fails
$avdSp = az ad sp list `
--filter "appId eq '9cdead84-a844-4324-93f2-b2e6bb768d07'" `
--query "[0].{id:id,displayName:displayName}" -o json | ConvertFrom-Json
# Get scaling plan via REST β works where Get-AzWvdScalingPlan fails
$scalingPlan = az rest `
--method GET `
--uri "https://management.azure.com/subscriptions/$subscriptionId/resourceGroups/$rg/providers/Microsoft.DesktopVirtualization/scalingPlans/$name?api-version=2023-09-05" | ConvertFrom-Json
# Associate via PATCH
$body = @{ properties = @{ hostPoolReferences = @(@{ hostPoolArmPath = $hostPoolId; scalingPlanEnabled = $true }) } } | ConvertTo-Json -Depth 5
$body | Set-Content -Path $tmp -Encoding UTF8
az rest --method PATCH --uri $scalingPlanUri --body "@$tmp" --headers "Content-Type=application/json"
The az rest approach also avoids the Az.DesktopVirtualization extension version problem β the CLI extension version 1.0.0 does not include scaling plan subcommands, but az rest bypasses the extension entirely and speaks directly to the ARM API.
If you are writing post-deployment scripts for AVD Marketplace offers and need them to work reliably across diverse customer environments, prefer az CLI calls over Az PowerShell cmdlets for any operations that touch:
az ad sp, az ad app)az rest against the DesktopVirtualization API)az role assignment)Reserve Az PowerShell for operations where it has a clear advantage and no known broker dependency issues, such as Connect-MgGraph for Microsoft Graph SDK operations where the CLI equivalent is more verbose.
Configuring FSLogix registry keys from an ARM template is straightforward once you know which delivery mechanism to use. The Custom Script Extension is the intuitive choice but has two failure modes that are difficult to diagnose and produce no useful error output: silent protectedSettings.script support on Windows, and the sequence number guard that fires when the ARM engine invokes the CSE handler multiple times during dependency resolution.
The reliable pattern for in-template configuration is:
deploymentScript resource with a copy loop to run one container per session hostInvoke-AzVMRunCommand inside the container to push and execute the script on the VMFor post-deployment scripts that run in the operatorβs local session, use Azure CLI calls throughout rather than Az PowerShell cmdlets for any operations that touch Entra ID, AVD resources, or role assignments. The Az module broker dependency issue is silent, environment-specific, and produces misleading authentication error messages that send you in the wrong direction.
Both patterns together give you a fully automated, reliably reproducible AVD deployment from a single Marketplace offer with minimal post-deployment manual intervention.
Frametype Solutions LLC β’ Azure Virtual Desktop Practice β’ March 2026