Automating Azure Virtual Desktop Migration Between Subscriptions, Regions & VNets

Moving Azure Virtual Desktop (AVD) session hosts between subscriptions isn’t a built-in feature of Azure, but it’s a common need for many customers. This PowerShell script automates the entire process end-to-end, including image capture, redeployment, host registration, user assignment migration, and finally, the removal of the old VMs. It enables you to migrate entire Personal Host Pools — along with all user applications, customizations, and local files — across Subscriptions, Regions, and VNets.

Test the script in a test hostpool/environment to understand and validate how it works!

The code can also be found in my GitHub

Script Breakdown

The script automates the migration of Azure Virtual Desktop Personal session hosts from a source subscription to a destination subscription.

  • Captures specialized images of all VMs in the source resource group into a Compute Gallery

  • Deploys new VMs in the destination subscription from those images

  • Creates new NICs and attaches them to a predefined destination VNet

  • Registers the new VMs to a destination AVD Host Pool using a fresh registration token

  • Migrates user assignments from the source Host Pool to the destination

  • Deletes the original VM from the source environment.
    (This step is optional and can be commented out)

Prerequisites & Considerations

  • You need to create an Azure Compute Gallery in the Source subscription and a specialized Image definition with the following properties:

    • OS Type: Windows

    • Image Configuration type: Specialized

    • VM Generation: V2

    • Publisher / Offer / SKU: just provide any string

  • Destination resource group, VNet and a Host Pool

  • The user / identity running this script must have proper RBAC permissions in both source and destination subscriptions (Owner on subs or resource groups will be best).

Other considerations:

  • This script assumes your session host names end with a digit — which is the default behaviour (I’ve personally never seen one that doesn’t). That digit is used to generate the image version for each VM. For example, AVD-VM-25 becomes image version 1.0.25.

  • It also assumes you're using Trusted Launch VMs. If you're not, you can adjust that setting in the $vmConfig part.

  • The script is currently set up for a single region, but you can modify it to support multiple regions if needed.

  • Important: If you plan to run the script multiple times across different host pools, make sure to increment the $imageVersion (e.g., use 1.1.$vmNumber) to avoid naming conflicts. If you reuse the same version number, the script will fail since the image name already exists.

  • Each VM is imaged individually, effectively serving as a backup. Once you confirm the migration is successful, you can safely delete those images — though the script doesn’t do this automatically, as a safety measure.

  • Migration takes about 15 minutes per VM. The process runs sequentially (no parallelism yet), but I may consider looking into parallel execution in a future update if there's demand.

  • This script was designed with personal host pools in mind. While you can technically use it to migrate pooled session hosts (just remove the user assignment logic), I haven’t tested that scenario.

  • This script is not intended to be used with FSLogix

How to Use It

Copy the script to your IDE and update all key variables:

$Source_subscription_Id = "source_sub_id" # Replace with your actual source subscription ID
$Destination_subscription_Id = "destination_sub_id" # Replace with your actual destination subscription ID
$SourcevmResourceGroupName = "source_sessionhosts_rg" # Replace with your sessionhosts source resource group name
$DestinationvmResourceGroupName= "destination_sessionhosts_rg"  # Replace with your sessionhosts destination resource group name
$galleryName = "gallery_name" # Replace with your gallery name
$galleryResourceGroupName = "compute_gallery_rg" # Replace with your gallery resource group name
$imageDefinitionName = "image_definition_name" # Replace with your image definition name
$location = "region_name" # Replace with your region name
$DestHPresourceGroupName = "destination_hostpool_rg" # Replace with your destination hostpool resource group name
$DesthostPoolName = "destination_hostpool_name" # Replace with your destination host pool name
$SourceHostPoolName = "source_hostpool_name" # Replace with your source host pool name
$vnetName = "destination_vnet_name" # Replace with your destination VNET name
$vnetrg = "destination_vnet_rg" # Replace with your destination VNET resource group name

Once completed run the script and connect to your tenant - It will then start the migration process according to the key variables you provided.

Full powershell script:

# Login to Azure
Connect-AzAccount
$scriptStartTime = Get-Date
Write-Output "Script started at: $($scriptStartTime.ToString('yyyy-MM-dd HH:mm:ss'))"

# Define variables
$Source_subscription_Id = "source_sub_id" # Replace with your actual source subscription ID
$Destination_subscription_Id = "destination_sub_id" # Replace with your actual destination subscription ID
$SourcevmResourceGroupName = "source_sessionhosts_rg" # Replace with your sessionhosts source resource group name
$DestinationvmResourceGroupName= "destination_sessionhosts_rg"  # Replace with your sessionhosts destination resource group name
$galleryName = "gallery_name" # Replace with your gallery name
$galleryResourceGroupName = "compute_gallery_rg" # Replace with your gallery resource group name
$imageDefinitionName = "image_definition_name" # Replace with your image definition name
$location = "region_name" # Replace with your region name
$DestHPresourceGroupName = "destination_hostpool_rg" # Replace with your destination hostpool resource group name
$DesthostPoolName = "destination_hostpool_name" # Replace with your destination host pool name
$SourceHostPoolName = "source_hostpool_name" # Replace with your source host pool name
$vnetName = "destination_vnet_name" # Replace with your destination VNET name
$vnetrg = "destination_vnet_rg" # Replace with your destination VNET resource group name
# Obtain destination hostpool RdsRegistrationInfotoken - valid for 12 hours
Set-AzContext -Subscription $Destination_subscription_Id -ErrorAction SilentlyContinue | Out-Null
$Registered = Get-AzWvdRegistrationInfo -SubscriptionId $Destination_subscription_Id -ResourceGroupName $DestHPresourceGroupName -HostPoolName $DesthostPoolName
if (-not(-Not $Registered.Token)){$registrationTokenValidFor = (NEW-TIMESPAN -Start (get-date) -End $Registered.ExpirationTime | select Days,Hours,Minutes,Seconds)}
$registrationTokenValidFor
if ((-Not $Registered.Token) -or ($Registered.ExpirationTime -le (get-date)))
{
    $Registered = New-AzWvdRegistrationInfo -SubscriptionId $Destination_subscription_Id -ResourceGroupName $DestHPresourceGroupName -HostPoolName $DesthostPoolName -ExpirationTime (Get-Date).AddHours(12) -ErrorAction SilentlyContinue
}
$registrationToken = $Registered.Token

Set-AzContext -Subscription $Source_subscription_Id -ErrorAction SilentlyContinue | Out-Null
# retrieve all VM's in the source resource group
$vmList = Get-AzVM -ResourceGroupName $SourcevmResourceGroupName
$successfulMigrations = @()

foreach ($vm in $vmList) {
    $migrationSuccess = $false
    try {
        Set-AzContext -Subscription $Source_subscription_Id
        $vmName = $vm.Name
        Write-Output "Starting migration for VM: $vmName"
    # Extract the number from the VM name to use in the image version
    $vmNumber = $vmName -replace '\D', ''

    # Set additional variables
    $imageVersion = "1.0.$vmNumber"

    # Get vm and set regional properties
    $vm_obj = Get-AzVM -ResourceGroupName $SourcevmResourceGroupName -Name $vmName
    $region1 = @{Name=$location;ReplicaCount=1}
    $targetRegions = @($region1)

    Set-AzContext -Subscription $Source_subscription_Id -ErrorAction SilentlyContinue | Out-Null

    #Capture the specialized image
    try {

    Write-output "Stopping & Capturing a VM image from $vmName. This will take about 10 minutes"
    Stop-AzVM -ResourceGroupName $SourcevmResourceGroupName -Name $vmName -Force
    New-AzGalleryImageVersion -GalleryImageDefinitionName $imageDefinitionName -GalleryImageVersionName $imageVersion -GalleryName $galleryName -ResourceGroupName $galleryResourceGroupName -Location $location -TargetRegion $targetRegions -SourceImageVMId $vm_obj.Id.ToString() #-PublishingProfileEndOfLifeDate '2030-12-01'
    $image = Get-AzGalleryImageVersion -ResourceGroupName $galleryResourceGroupName -GalleryName $galleryName -GalleryImageDefinitionName $imageDefinitionName -GalleryImageVersionName $imageVersion
    Write-output "VM Image created succesfully"

    } catch {
        Write-Output "Failed capturing image: $_"
        Write-Output "Skipping to next VM..."
        continue
    }

    Set-AzContext -Subscription $Destination_subscription_Id -ErrorAction SilentlyContinue | Out-Null
    $vnet = Get-AzVirtualNetwork -Name $vnetName -ResourceGroupName $vnetrg

    # Create a new NIC
    Write-output "Re-Deploying VM $vmname in $DestinationvmResourceGroupName, in Destination subscription. This will take a few minutes"

    # create a new NIC in destination VNET/Subnet
    try {
    $nic = New-AzNetworkInterface -ResourceGroupName $DestinationvmResourceGroupName -Location $location -Name "$vmName-nic" -SubnetId $vnet.Subnets[0].Id

    # Create a new VM configuration
    Set-AzContext -Subscription $Source_subscription_Id -ErrorAction SilentlyContinue | Out-Null
    $vmConfig = New-AzVMConfig -VMName $vmName -VMSize "Standard_D4ds_v5"
    $vmConfig = Set-AzVMSourceImage -VM $vmConfig -Id $image.Id
    $vmConfig = Add-AzVMNetworkInterface -VM $vmConfig -Id $nic.Id
    $vmConfig = Set-AzVMOSDisk -VM $vmConfig -Name "osdisk$vmname" -CreateOption FromImage -StorageAccountType "StandardSSD_LRS" 
    $vmConfig.SecurityProfile = @{
        SecurityType = "TrustedLaunch"
    }

    # Create the VM in the destination resource group from the captured image
    Set-AzContext -Subscription $Destination_subscription_Id -ErrorAction SilentlyContinue | Out-Null
    New-AzVM -ResourceGroupName $DestinationvmResourceGroupName -Location $location -VM $vmConfig
    Write-output "VM deployment completed successfully"

    } catch {
        Write-Output "Failed Creating new VM from image: $_"
        Write-Output "Skipping to next VM..."
        continue
    }

    # Register the VM to the destination host pool
    try {
        Write-Output "Registering $vmName to Host Pool $DesthostPoolName"
        Set-AzContext -Subscription $Destination_subscription_Id -ErrorAction SilentlyContinue | Out-Null

        $script = @"
Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\RDInfraAgent" -Name "IsRegistered" -Value 0 -Force
Set-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\RDInfraAgent" -Name "RegistrationToken" -Value $registrationToken -Force
Restart-Service RDAgentBootLoader
"@

        # Invoke command to register the VM to the host pool
        Invoke-AzVMRunCommand -ResourceGroupName $DestinationvmResourceGroupName -Name $vmName -CommandId 'RunPowerShellScript' -ScriptString $script
        Restart-AzVM -ResourceGroupName $DestinationvmResourceGroupName -Name $vmName
        Write-Output "VM registered to host pool successfully"

        # --- MIGRATE USER ASSIGNMENTS FROM SOURCE HOSTPOOL TO DESTINATION HOSTPOOL ---

        Set-AzContext -Subscription $Source_subscription_Id -ErrorAction SilentlyContinue | Out-Null
        $sourceSessionHostName = $vmName 
        $assignedUser = ($userSessions = Get-AzWvdSessionHost -ResourceGroupName $SourcevmResourceGroupName -HostPoolName $SourceHostPoolName -SessionHostName $sourceSessionHostName).AssignedUser
        if ($assignedUser) {
            Write-Output "Assigning user $assignedUser to $vmName in $DesthostPoolName"
            Set-AzContext -Subscription $Destination_subscription_Id -ErrorAction SilentlyContinue | Out-Null
            Update-AzWvdSessionHost -ResourceGroupName $DestHPresourceGroupName -HostPoolName $DestHostPoolName -SessionHostName $vmName -AssignedUser $assignedUser
        } else {
            Write-Output "No user sessions found for $sourceSessionHostName in $SourceHostPoolName."
        }

        # Mark migration as successful
        $migrationSuccess = $true
        $successfulMigrations += $vmName
        Write-Output "Migration completed successfully for $vmName"

    } catch {
        Write-Output "Failed registering VM or migrating users: $_"
        Write-Output "Skipping to next VM..."
        continue
    }

    } catch {
        Write-Output "Migration failed for $vmName`: $_"
        continue
    }
}

# Delete successfully migrated VMs from source subscription
$scriptEndTime = Get-Date
$totalRuntime = New-TimeSpan -Start $scriptStartTime -End $scriptEndTime
Write-Output "Migration Summary: $($successfulMigrations.Count) out of $($vmList.Count) VMs migrated successfully"
Write-Output "Total script runtime: $($totalRuntime.Hours)h $($totalRuntime.Minutes)m $($totalRuntime.Seconds)s"
Write-Output "Script completed at: $($scriptEndTime.ToString('yyyy-MM-dd HH:mm:ss'))"
if ($successfulMigrations.Count -gt 0) {
    Write-Output "Successfully migrated VMs: $($successfulMigrations -join ', ')"

    # Uncomment the section below if you want to delete source VMs after successful migration
    Write-Output "Deleting source VMs for successfully migrated machines..."
    Set-AzContext -Subscription $Source_subscription_Id -ErrorAction SilentlyContinue | Out-Null
    foreach ($vmToDelete in $successfulMigrations) {
        try {
            Write-Output "Deleting source VM: $vmToDelete"
            Remove-AzVM -ResourceGroupName $SourcevmResourceGroupName -Name $vmToDelete -Force | Out-Null
            Write-Output "Successfully deleted source VM: $vmToDelete"
        } catch {
            Write-Output "Failed to delete source VM $vmToDelete`: $_"
        }
    }
}
0
Subscribe to my newsletter

Read articles from Michael Tsukerman directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Michael Tsukerman
Michael Tsukerman