Skip to content

Patch Management

πŸ”„ Bringing Patch Management In-House: Migrating from MSP to Azure Update Manager

It's all fun and games until the MSP contract expires and you realise 90 VMs still need their patching schedules sorted…

With our MSP contract winding down, the time had come to bring VM patching back in house. Our third-party provider had been handling it with their own tooling, which would no longer be used when the service contract expired.

Enter Azure Update Manager β€” the modern, agentless way to manage patching schedules across your Azure VMs. Add a bit of PowerShell, sprinkle in some Azure Policy, and you've got yourself a scalable, policy-driven solution that's more visible, auditable, and way more maintainable.

Here's how I made the switch β€” and managed to avoid a patching panic.


βš™οΈ Prerequisites & Permissions

Let's get the plumbing sorted before diving in.

You'll need:

  • The right PowerShell modules:
Install-Module Az -Scope CurrentUser -Force
Import-Module Az.Maintenance, Az.Resources, Az.Compute
  • An account with Contributor permissions (or higher)
  • Registered providers to avoid mysterious error messages:
Register-AzResourceProvider -ProviderNamespace Microsoft.Maintenance
Register-AzResourceProvider -ProviderNamespace Microsoft.GuestConfiguration

Why Resource Providers? Azure Update Manager needs these registered to create the necessary API endpoints and resource types in your subscription. Without them, you'll get cryptic "resource type not found" errors.

Official documentation on Azure Update Manager prerequisites


πŸ•΅οΈ Step 1 – Audit the Current Setup

First order of business: collect the patching summary data from the MSP β€” which, helpfully, came in the form of multiple weekly CSV exports.

I used GenAI to wrangle the mess into a structured format. The result was a clear categorisation of VMs based on the day and time they were typically patched β€” a solid foundation to work from.


🧱 Step 2 – Create Seven New Maintenance Configurations

This is the foundation of Update Manager β€” define your recurring patch windows.

Click to expand: Create Maintenance Configurations (Sample Script)
# Azure Update Manager - Create Weekly Maintenance Configurations
# Pure PowerShell syntax

# Define parameters
$resourceGroupName = "rg-maintenance-uksouth-001"
$location = "uksouth"
$timezone = "GMT Standard Time"
$startDateTime = "2024-06-01 21:00"
$duration = "03:00"  # 3 hours - meets minimum requirement

# Day mapping for config naming (3-letter lowercase)
$dayMap = @{
    "Monday"    = "mon"
    "Tuesday"   = "tue" 
    "Wednesday" = "wed"
    "Thursday"  = "thu"
    "Friday"    = "fri"
    "Saturday"  = "sat"
    "Sunday"    = "sun"
}

# Create maintenance configurations for each day
foreach ($day in $dayMap.Keys) {
    $shortDay = $dayMap[$day]
    $configName = "contoso-maintenance-config-vms-$shortDay"

    Write-Host "Creating: $configName for $day..." -ForegroundColor Yellow

    try {
        $result = New-AzMaintenanceConfiguration `
            -ResourceGroupName $resourceGroupName `
            -Name $configName `
            -MaintenanceScope "InGuestPatch" `
            -Location $location `
            -StartDateTime $startDateTime `
            -Timezone $timezone `
            -Duration $duration `
            -RecurEvery "Week $day" `
            -InstallPatchRebootSetting "IfRequired" `
            -ExtensionProperty @{"InGuestPatchMode" = "User"} `
            -WindowParameterClassificationToInclude @("Critical", "Security") `
            -LinuxParameterClassificationToInclude @("Critical", "Security") `
            -Tag @{
                "Application"  = "Azure Update Manager"
                "Owner"        = "Contoso"
                "PatchWindow"  = $shortDay
            } `
            -ErrorAction Stop

        Write-Host "βœ“ SUCCESS: $configName" -ForegroundColor Green

        # Quick validation
        $createdConfig = Get-AzMaintenanceConfiguration -ResourceGroupName $resourceGroupName -Name $configName
        Write-Host "  Validated: $($createdConfig.RecurEvery) schedule confirmed" -ForegroundColor Gray

    } catch {
        Write-Host "βœ— FAILED: $configName - $($_.Exception.Message)" -ForegroundColor Red
        continue
    }
}

⚠️ Don't forget: duration format is ISO 8601, not "2 hours" β€” and start time has to match the day it's tied to.

Learn more about New-AzMaintenanceConfiguration


πŸ› οΈ Step 3 – Tweak the Maintenance Configs

Some patch windows felt too tight β€” and, just as importantly, I needed to avoid overlaps with existing backup jobs. Rather than let a large CU fail halfway through or run headlong into an Azure Backup job, I extended the duration on select configs and staggered them across the week:

$config = Get-AzMaintenanceConfiguration -ResourceGroupName "rg-maintenance-uksouth-001" -Name "contoso-maintenance-config-vms-sun"
$config.Duration = "04:00"
Update-AzMaintenanceConfiguration -ResourceGroupName "rg-maintenance-uksouth-001" -Name "contoso-maintenance-config-vms-sun" -Configuration $config

# Verify the change
$updatedConfig = Get-AzMaintenanceConfiguration -ResourceGroupName "rg-maintenance-uksouth-001" -Name "contoso-maintenance-config-vms-sun"
Write-Host "Sunday window now: $($updatedConfig.Duration) duration" -ForegroundColor Green

Learn more about Update-AzMaintenanceConfiguration


πŸ€– Step 4 – Use AI to Group VMs by Patch Activity

Armed with CSV exports of the latest patching summaries, I got AI to do the grunt work and make sense of the contents.

What I did:

  1. Exported MSP data: Weekly CSV reports showing patch installation timestamps for each VM
  2. Used Gen AI with various iterative prompts, starting the conversation with this:

    "Attached is an export summary of the current patching activity from our incumbent MSP who currently look after the patching of the VM's in Azure I need you to review timestamps and work out which maintenance window each vm is currently in, and then match that to the appropriate maintenance config that we have just created. If there are mis matches in new and current schedule then we may need to tweak the settings of the new configs"

  3. AI analysis revealed:

  4. 60% of VMs were patching on one weekday evening
  5. Several critical systems patching simultaneously
  6. No consideration for application dependencies

  7. AI recommendation: Spread VMs across weekdays based on:

  8. Criticality: Domain controllers on different days
  9. Function: Similar servers on different days (avoid single points of failure)
  10. Dependencies: Database servers before application servers

The result: A logical rebalancing that avoided "all our eggs in Sunday 1AM" basket and considered business impact.

Why this matters: The current patching schedule was not optimized for business continuity. AI helped identify risks we hadn't considered.


πŸ” Step 5 – Discover All VMs and Identify Gaps

Before diving into bulk tagging, I needed to understand what we were working with across all subscriptions.

First, let's see what VMs we have:

Click to expand: Discover Untagged VMs (Sample Script)
# Discover Untagged VMs Script for Azure Update Manager
# This script identifies VMs that are missing Azure Update Manager tags

$scriptStart = Get-Date

Write-Host "=== Azure Update Manager - Discover Untagged VMs ===" -ForegroundColor Cyan
Write-Host "Scanning all accessible subscriptions for VMs missing maintenance tags..." -ForegroundColor White
Write-Host ""

# Function to check if VM has Azure Update Manager tags
function Test-VMHasMaintenanceTags {
    param($VM)

    # Check for the three required tags
    $hasOwnerTag = $VM.Tags -and $VM.Tags.ContainsKey("Owner") -and $VM.Tags["Owner"] -eq "Contoso"
    $hasUpdatesTag = $VM.Tags -and $VM.Tags.ContainsKey("Updates") -and $VM.Tags["Updates"] -eq "Azure Update Manager"
    $hasPatchWindowTag = $VM.Tags -and $VM.Tags.ContainsKey("PatchWindow")

    return $hasOwnerTag -and $hasUpdatesTag -and $hasPatchWindowTag
}

# Function to get VM details for reporting
function Get-VMDetails {
    param($VM, $SubscriptionName)

    return [PSCustomObject]@{
        Name = $VM.Name
        ResourceGroup = $VM.ResourceGroupName
        Location = $VM.Location
        Subscription = $SubscriptionName
        SubscriptionId = $VM.SubscriptionId
        PowerState = $VM.PowerState
        OsType = $VM.StorageProfile.OsDisk.OsType
        VmSize = $VM.HardwareProfile.VmSize
        Tags = if ($VM.Tags) { ($VM.Tags.Keys | ForEach-Object { "$_=$($VM.Tags[$_])" }) -join "; " } else { "No tags" }
    }
}

# Initialize collections
$taggedVMs = @()
$untaggedVMs = @()
$allVMs = @()
$subscriptionSummary = @{}

Write-Host "=== DISCOVERING VMs ACROSS ALL SUBSCRIPTIONS ===" -ForegroundColor Cyan

# Get all accessible subscriptions
$subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" }
Write-Host "Found $($subscriptions.Count) accessible subscriptions" -ForegroundColor White

foreach ($subscription in $subscriptions) {
    try {
        Write-Host "`nScanning subscription: $($subscription.Name) ($($subscription.Id))" -ForegroundColor Magenta
        $null = Set-AzContext -SubscriptionId $subscription.Id -ErrorAction Stop

        # Get all VMs in this subscription
        Write-Host "  Retrieving VMs..." -ForegroundColor Gray
        $vms = Get-AzVM -Status -ErrorAction Continue

        $subTagged = 0
        $subUntagged = 0
        $subTotal = $vms.Count

        Write-Host "  Found $subTotal VMs in this subscription" -ForegroundColor White

        foreach ($vm in $vms) {
            $vmDetails = Get-VMDetails -VM $vm -SubscriptionName $subscription.Name
            $allVMs += $vmDetails

            if (Test-VMHasMaintenanceTags -VM $vm) {
                $taggedVMs += $vmDetails
                $subTagged++
                Write-Host "    βœ“ Tagged: $($vm.Name)" -ForegroundColor Green
            } else {
                $untaggedVMs += $vmDetails
                $subUntagged++
                Write-Host "    ⚠️ Untagged: $($vm.Name)" -ForegroundColor Yellow
            }
        }

        # Store subscription summary
        $subscriptionSummary[$subscription.Name] = @{
            Total = $subTotal
            Tagged = $subTagged
            Untagged = $subUntagged
            SubscriptionId = $subscription.Id
        }

        Write-Host "  Subscription Summary - Total: $subTotal | Tagged: $subTagged | Untagged: $subUntagged" -ForegroundColor Gray

    }
    catch {
        Write-Host "  βœ— Error scanning subscription $($subscription.Name): $($_.Exception.Message)" -ForegroundColor Red
        $subscriptionSummary[$subscription.Name] = @{
            Total = 0
            Tagged = 0
            Untagged = 0
            Error = $_.Exception.Message
        }
    }
}

Write-Host ""
Write-Host "=== OVERALL DISCOVERY SUMMARY ===" -ForegroundColor Cyan
Write-Host "Total VMs found: $($allVMs.Count)" -ForegroundColor White
Write-Host "VMs with maintenance tags: $($taggedVMs.Count)" -ForegroundColor Green
Write-Host "VMs missing maintenance tags: $($untaggedVMs.Count)" -ForegroundColor Red

if ($untaggedVMs.Count -eq 0) {
    Write-Host "οΏ½ ALL VMs ARE ALREADY TAGGED! οΏ½" -ForegroundColor Green
    Write-Host "No further action required." -ForegroundColor White
    exit 0
}

Write-Host ""
Write-Host "=== SUBSCRIPTION BREAKDOWN ===" -ForegroundColor Cyan
$subscriptionSummary.GetEnumerator() | Sort-Object Name | ForEach-Object {
    $sub = $_.Value
    if ($sub.Error) {
        Write-Host "$($_.Key): ERROR - $($sub.Error)" -ForegroundColor Red
    } else {
        $percentage = if ($sub.Total -gt 0) { [math]::Round(($sub.Tagged / $sub.Total) * 100, 1) } else { 0 }
        Write-Host "$($_.Key): $($sub.Tagged)/$($sub.Total) tagged ($percentage%)" -ForegroundColor White
    }
}

Write-Host ""
Write-Host "=== UNTAGGED VMs DETAILED LIST ===" -ForegroundColor Red
Write-Host "The following $($untaggedVMs.Count) VMs are missing Azure Update Manager maintenance tags:" -ForegroundColor White

# Group untagged VMs by subscription for easier reading
$untaggedBySubscription = $untaggedVMs | Group-Object Subscription

foreach ($group in $untaggedBySubscription | Sort-Object Name) {
    Write-Host "`nοΏ½ Subscription: $($group.Name) ($($group.Count) untagged VMs)" -ForegroundColor Magenta

    $group.Group | Sort-Object Name | ForEach-Object {
        Write-Host "  β€’ $($_.Name)" -ForegroundColor Yellow
        Write-Host "    Resource Group: $($_.ResourceGroup)" -ForegroundColor Gray
        Write-Host "    Location: $($_.Location)" -ForegroundColor Gray
        Write-Host "    OS Type: $($_.OsType)" -ForegroundColor Gray
        Write-Host "    VM Size: $($_.VmSize)" -ForegroundColor Gray
        Write-Host "    Power State: $($_.PowerState)" -ForegroundColor Gray
        if ($_.Tags -ne "No tags") {
            Write-Host "    Existing Tags: $($_.Tags)" -ForegroundColor DarkGray
        }
        Write-Host ""
    }
}

Write-Host "=== ANALYSIS BY VM CHARACTERISTICS ===" -ForegroundColor Cyan

# Analyze by OS Type
$untaggedByOS = $untaggedVMs | Group-Object OsType
Write-Host "`nοΏ½ Untagged VMs by OS Type:" -ForegroundColor White
$untaggedByOS | Sort-Object Name | ForEach-Object {
    Write-Host "  $($_.Name): $($_.Count) VMs" -ForegroundColor White
}

# Analyze by Location
$untaggedByLocation = $untaggedVMs | Group-Object Location
Write-Host "`nοΏ½ Untagged VMs by Location:" -ForegroundColor White
$untaggedByLocation | Sort-Object Count -Descending | ForEach-Object {
    Write-Host "  $($_.Name): $($_.Count) VMs" -ForegroundColor White
}

# Analyze by VM Size (to understand workload types)
$untaggedBySize = $untaggedVMs | Group-Object VmSize
Write-Host "`nοΏ½ Untagged VMs by Size:" -ForegroundColor White
$untaggedBySize | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object {
    Write-Host "  $($_.Name): $($_.Count) VMs" -ForegroundColor White
}

# Analyze by Resource Group (might indicate application/workload groupings)
$untaggedByRG = $untaggedVMs | Group-Object ResourceGroup
Write-Host "`nοΏ½ Untagged VMs by Resource Group (Top 10):" -ForegroundColor White
$untaggedByRG | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object {
    Write-Host "  $($_.Name): $($_.Count) VMs" -ForegroundColor White
}

Write-Host ""
Write-Host "=== POWER STATE ANALYSIS ===" -ForegroundColor Cyan
$powerStates = $untaggedVMs | Group-Object PowerState
$powerStates | Sort-Object Count -Descending | ForEach-Object {
    Write-Host "$($_.Name): $($_.Count) VMs" -ForegroundColor White
}

Write-Host ""
Write-Host "=== EXPORT OPTIONS ===" -ForegroundColor Cyan
Write-Host "You can export this data for further analysis:" -ForegroundColor White

# Export to CSV option
$timestamp = Get-Date -Format "yyyyMMdd-HHmm"
$csvPath = "D:\UntaggedVMs-$timestamp.csv"

try {
    $untaggedVMs | Export-Csv -Path $csvPath -NoTypeInformation
    Write-Host "βœ“ Exported untagged VMs to: $csvPath" -ForegroundColor Green
} catch {
    Write-Host "βœ— Failed to export CSV: $($_.Exception.Message)" -ForegroundColor Red
}

# Show simple list for easy copying
Write-Host ""
Write-Host "=== SIMPLE VM NAME LIST (for copy/paste) ===" -ForegroundColor Cyan
Write-Host "VM Names:" -ForegroundColor White
$untaggedVMs | Sort-Object Name | ForEach-Object { Write-Host "  $($_.Name)" -ForegroundColor Yellow }

Write-Host ""
Write-Host "=== NEXT STEPS RECOMMENDATIONS ===" -ForegroundColor Cyan
Write-Host "1. Review the untagged VMs list above" -ForegroundColor White
Write-Host "2. Investigate why these VMs were not in the original patching schedule" -ForegroundColor White
Write-Host "3. Determine appropriate maintenance windows for these VMs" -ForegroundColor White
Write-Host "4. Consider grouping by:" -ForegroundColor White
Write-Host "   β€’ Application/workload (Resource Group analysis)" -ForegroundColor Gray
Write-Host "   β€’ Environment (naming patterns, tags)" -ForegroundColor Gray
Write-Host "   β€’ Business criticality" -ForegroundColor Gray
Write-Host "   β€’ Maintenance window preferences" -ForegroundColor Gray
Write-Host "5. Run the tagging script to assign maintenance windows" -ForegroundColor White

Write-Host ""
Write-Host "=== AZURE RESOURCE GRAPH QUERY ===" -ForegroundColor Cyan
Write-Host "Use this query in Azure Resource Graph Explorer to verify results:" -ForegroundColor White
Write-Host ""
Write-Host @"
Resources
| where type == "microsoft.compute/virtualmachines"
| where tags.PatchWindow == "" or isempty(tags.PatchWindow) or isnull(tags.PatchWindow)
| project name, resourceGroup, subscriptionId, location, 
          osType = properties.storageProfile.osDisk.osType,
          vmSize = properties.hardwareProfile.vmSize,
          powerState = properties.extended.instanceView.powerState.displayStatus,
          tags
| sort by name asc
"@ -ForegroundColor Gray

Write-Host ""
Write-Host "Script completed at $(Get-Date)" -ForegroundColor Cyan
Write-Host "Total runtime: $((Get-Date) - $scriptStart)" -ForegroundColor Gray

Discovery results:

  • 35 VMs from the original MSP schedule (our planned list)
  • 12 additional VMs not in the MSP schedule (the "stragglers")
  • Total: 90 VMs needing Update Manager tags

Key insight: The MSP wasn't managing everything. Several dev/test VMs and a few production systems were missing from their schedule.


✍️ Step 6 – Bulk Tag All VMs with Patch Windows

Now for the main event: tagging all VMs with their maintenance windows. This includes both our planned VMs and the newly discovered ones.

🎯 Main VM Tagging (Planned Schedule)

Each tag serves a specific purpose:

  • PatchWindow β€” The key tag used by dynamic scopes to assign VMs to maintenance configurations
  • Owner β€” For accountability and filtering
  • Updates β€” Identifies VMs managed by Azure Update Manager
Click to expand: Multi-Subscription Azure Update Manager VM Tagging (Sample Script)
# Multi-Subscription Azure Update Manager VM Tagging Script
# This script discovers VMs across multiple subscriptions and tags them appropriately

Write-Host "=== Multi-Subscription Azure Update Manager - VM Tagging Script ===" -ForegroundColor Cyan

# Function to safely tag a VM
function Set-VMMaintenanceTags {
    param(
        [string]$VMName,
        [string]$ResourceGroupName,
        [string]$SubscriptionId,
        [hashtable]$Tags,
        [string]$MaintenanceWindow
    )

    try {
        # Set context to the VM's subscription
        $null = Set-AzContext -SubscriptionId $SubscriptionId -ErrorAction Stop

        Write-Host "  Processing: $VMName..." -ForegroundColor Yellow

        # Get the VM and update tags
        $vm = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VMName -ErrorAction Stop

        if ($vm.Tags) {
            $Tags.Keys | ForEach-Object { $vm.Tags[$_] = $Tags[$_] }
        } else {
            $vm.Tags = $Tags
        }

        $null = Update-AzVM -VM $vm -ResourceGroupName $ResourceGroupName -Tag $vm.Tags -ErrorAction Stop
        Write-Host "  βœ“ Successfully tagged $VMName for $MaintenanceWindow maintenance" -ForegroundColor Green

        return $true
    }
    catch {
        Write-Host "  βœ— Failed to tag $VMName`: $($_.Exception.Message)" -ForegroundColor Red
        return $false
    }
}

# Define all target VMs organized by maintenance window
$maintenanceGroups = @{
    "Monday" = @{
        "VMs" = @("WEB-PROD-01", "DB-PROD-01", "APP-PROD-01", "FILE-PROD-01", "DC-PROD-01")
        "Tags" = @{
            "Owner" = "Contoso"
            "Updates" = "Azure Update Manager"
            "PatchWindow" = "mon"
        }
    }
    "Tuesday" = @{
        "VMs" = @("WEB-PROD-02", "DB-PROD-02", "APP-PROD-02", "FILE-PROD-02", "DC-PROD-02")
        "Tags" = @{
            "Owner" = "Contoso"
            "Updates" = "Azure Update Manager"
            "PatchWindow" = "tue"
        }
    }
    "Wednesday" = @{
        "VMs" = @("WEB-PROD-03", "DB-PROD-03", "APP-PROD-03", "FILE-PROD-03", "DC-PROD-03")
        "Tags" = @{
            "Owner" = "Contoso"
            "Updates" = "Azure Update Manager"
            "PatchWindow" = "wed"
        }
    }
    "Thursday" = @{
        "VMs" = @("WEB-PROD-04", "DB-PROD-04", "APP-PROD-04", "FILE-PROD-04", "PRINT-PROD-01")
        "Tags" = @{
            "Owner" = "Contoso"
            "Updates" = "Azure Update Manager"
            "PatchWindow" = "thu"
        }
    }
    "Friday" = @{
        "VMs" = @("WEB-PROD-05", "DB-PROD-05", "APP-PROD-05", "FILE-PROD-05", "MONITOR-PROD-01")
        "Tags" = @{
            "Owner" = "Contoso"
            "Updates" = "Azure Update Manager"
            "PatchWindow" = "fri"
        }
    }
    "Saturday" = @{
        "VMs" = @("WEB-DEV-01", "DB-DEV-01", "APP-DEV-01", "TEST-SERVER-01", "SANDBOX-01")
        "Tags" = @{
            "Owner" = "Contoso"
            "Updates" = "Azure Update Manager"
            "PatchWindow" = "sat-09"
        }
    }
    "Sunday" = @{
        "VMs" = @("WEB-UAT-01", "DB-UAT-01", "APP-UAT-01", "BACKUP-PROD-01", "MGMT-PROD-01")
        "Tags" = @{
            "Owner" = "Contoso"
            "Updates" = "Azure Update Manager"
            "PatchWindow" = "sun"
        }
    }
}

# Function to discover VMs across all subscriptions
function Find-VMsAcrossSubscriptions {
    param([array]$TargetVMNames)

    $subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" }
    $vmInventory = @{}

    foreach ($subscription in $subscriptions) {
        try {
            $null = Set-AzContext -SubscriptionId $subscription.Id -ErrorAction Stop
            $vms = Get-AzVM -ErrorAction Continue

            foreach ($vm in $vms) {
                if ($vm.Name -in $TargetVMNames) {
                    $vmInventory[$vm.Name] = @{
                        Name = $vm.Name
                        ResourceGroupName = $vm.ResourceGroupName
                        SubscriptionId = $subscription.Id
                        SubscriptionName = $subscription.Name
                        Location = $vm.Location
                    }
                }
            }
        }
        catch {
            Write-Host "Error scanning subscription $($subscription.Name): $($_.Exception.Message)" -ForegroundColor Red
        }
    }

    return $vmInventory
}

# Get all unique VM names and discover their locations
$allTargetVMs = @()
$maintenanceGroups.Values | ForEach-Object { $allTargetVMs += $_.VMs }
$allTargetVMs = $allTargetVMs | Sort-Object -Unique

Write-Host "Discovering locations for $($allTargetVMs.Count) target VMs..." -ForegroundColor White
$vmInventory = Find-VMsAcrossSubscriptions -TargetVMNames $allTargetVMs

# Process each maintenance window
$totalSuccess = 0
$totalFailed = 0

foreach ($windowName in $maintenanceGroups.Keys) {
    $group = $maintenanceGroups[$windowName]
    Write-Host "`n=== $windowName MAINTENANCE WINDOW ===" -ForegroundColor Magenta

    foreach ($vmName in $group.VMs) {
        if ($vmInventory.ContainsKey($vmName)) {
            $vmInfo = $vmInventory[$vmName]
            $result = Set-VMMaintenanceTags -VMName $vmInfo.Name -ResourceGroupName $vmInfo.ResourceGroupName -SubscriptionId $vmInfo.SubscriptionId -Tags $group.Tags -MaintenanceWindow $windowName
            if ($result) { $totalSuccess++ } else { $totalFailed++ }
        } else {
            Write-Host "  ⚠️ VM not found: $vmName" -ForegroundColor Yellow
            $totalFailed++
        }
    }
}

Write-Host "`n=== TAGGING SUMMARY ===" -ForegroundColor Cyan
Write-Host "Successfully tagged: $totalSuccess VMs" -ForegroundColor Green
Write-Host "Failed to tag: $totalFailed VMs" -ForegroundColor Red

🧹 Handle the Stragglers

For the 12 VMs not in the original MSP schedule, I used intelligent assignment based on their function:

Click to expand: Tagging Script for Remaining Untagged VMs (Sample Script)
# Intelligent VM Tagging Script for Remaining Untagged VMs
# This script analyzes and tags the remaining VMs based on workload patterns and load balancing

$scriptStart = Get-Date

Write-Host "=== Intelligent VM Tagging for Remaining VMs ===" -ForegroundColor Cyan
Write-Host "Analyzing and tagging 26 untagged VMs with optimal maintenance window distribution..." -ForegroundColor White
Write-Host ""

# Function to safely tag a VM across subscriptions
function Set-VMMaintenanceTags {
    param(
        [string]$VMName,
        [string]$ResourceGroupName,
        [string]$SubscriptionId,
        [hashtable]$Tags,
        [string]$MaintenanceWindow
    )

    try {
        # Set context to the VM's subscription
        $currentContext = Get-AzContext
        if ($currentContext.Subscription.Id -ne $SubscriptionId) {
            $null = Set-AzContext -SubscriptionId $SubscriptionId -ErrorAction Stop
        }

        Write-Host "  Processing: $VMName..." -ForegroundColor Yellow

        # Get the VM
        $vm = Get-AzVM -ResourceGroupName $ResourceGroupName -Name $VMName -ErrorAction Stop

        # Add maintenance tags to existing tags (preserve existing tags)
        if ($vm.Tags) {
            $Tags.Keys | ForEach-Object {
                $vm.Tags[$_] = $Tags[$_]
            }
        } else {
            $vm.Tags = $Tags
        }

        # Update the VM tags
        $null = Update-AzVM -VM $vm -ResourceGroupName $ResourceGroupName -Tag $vm.Tags -ErrorAction Stop
        Write-Host "  βœ“ Successfully tagged $VMName for $MaintenanceWindow maintenance" -ForegroundColor Green

        return $true
    }
    catch {
        Write-Host "  βœ— Failed to tag $VMName`: $($_.Exception.Message)" -ForegroundColor Red
        return $false
    }
}

# Define current maintenance window loads (after existing 59 VMs)
$currentLoad = @{
    "Monday" = 7
    "Tuesday" = 7 
    "Wednesday" = 10
    "Thursday" = 6
    "Friday" = 6
    "Saturday" = 17  # Dev/Test at 09:00
    "Sunday" = 6
}

Write-Host "=== CURRENT MAINTENANCE WINDOW LOAD ===" -ForegroundColor Cyan
$currentLoad.GetEnumerator() | Sort-Object Name | ForEach-Object {
    Write-Host "$($_.Key): $($_.Value) VMs" -ForegroundColor White
}

# Initialize counters for new assignments
$newAssignments = @{
    "Monday" = 0
    "Tuesday" = 0
    "Wednesday" = 0
    "Thursday" = 0
    "Friday" = 0
    "Saturday" = 0  # Will use sat-09 for dev/test
    "Sunday" = 0
}

Write-Host ""
Write-Host "=== INTELLIGENT VM GROUPING AND ASSIGNMENT ===" -ForegroundColor Cyan

# Define VM groups with intelligent maintenance window assignments
$vmGroups = @{

    # CRITICAL PRODUCTION SYSTEMS - Spread across different days
    "Critical Infrastructure" = @{
        "VMs" = @(
            @{ Name = "DC-PROD-01"; RG = "rg-infrastructure"; Sub = "Production"; Window = "Sunday"; Reason = "Domain Controller - critical infrastructure" },
            @{ Name = "DC-PROD-02"; RG = "rg-infrastructure"; Sub = "Production"; Window = "Monday"; Reason = "Domain Controller - spread from other DCs" },
            @{ Name = "BACKUP-PROD-01"; RG = "rg-backup"; Sub = "Production"; Window = "Tuesday"; Reason = "Backup Server - spread across week" }
        )
    }

    # PRODUCTION BUSINESS APPLICATIONS - Spread for business continuity
    "Production Applications" = @{
        "VMs" = @(
            @{ Name = "WEB-PROD-01"; RG = "rg-web-production"; Sub = "Production"; Window = "Monday"; Reason = "Web Server - Monday for week start" },
            @{ Name = "DB-PROD-01"; RG = "rg-database-production"; Sub = "Production"; Window = "Tuesday"; Reason = "Database Server - Tuesday" },
            @{ Name = "APP-PROD-01"; RG = "rg-app-production"; Sub = "Production"; Window = "Wednesday"; Reason = "Application Server - mid-week" }
        )
    }

    # DEV/TEST SYSTEMS - Saturday morning maintenance (like existing dev/test)
    "Development Systems" = @{
        "VMs" = @(
            @{ Name = "WEB-DEV-01"; RG = "rg-web-development"; Sub = "Development"; Window = "Saturday"; Reason = "Web Dev - join existing dev/test window" },
            @{ Name = "DB-DEV-01"; RG = "rg-database-development"; Sub = "Development"; Window = "Saturday"; Reason = "Database Dev - join existing dev/test window" },
            @{ Name = "TEST-SERVER-01"; RG = "rg-testing"; Sub = "Development"; Window = "Saturday"; Reason = "Test Server - join existing dev/test window" }
            # ... additional dev/test VMs
        )
    }
}

# Initialize counters
$totalProcessed = 0
$totalSuccess = 0
$totalFailed = 0

# Process each group
foreach ($groupName in $vmGroups.Keys) {
    $group = $vmGroups[$groupName]
    Write-Host "`n=== $groupName ===" -ForegroundColor Magenta
    Write-Host "Processing $($group.VMs.Count) VMs in this group" -ForegroundColor White

    foreach ($vmInfo in $group.VMs) {
        $window = $vmInfo.Window
        $vmName = $vmInfo.Name

        Write-Host "`n�️ $vmName β†’ $window maintenance window" -ForegroundColor Yellow
        Write-Host "   Reason: $($vmInfo.Reason)" -ForegroundColor Gray

        # Determine subscription ID from name
        $subscriptionId = switch ($vmInfo.Sub) {
            "Production" { (Get-AzSubscription -SubscriptionName "Production").Id }
            "DevTest" { (Get-AzSubscription -SubscriptionName "DevTest").Id }
            "Identity" { (Get-AzSubscription -SubscriptionName "Identity").Id }
            "DMZ" { (Get-AzSubscription -SubscriptionName "DMZ").Id }
        }

        # Create appropriate tags based on maintenance window
        $tags = @{
            "Owner" = "Contoso"
            "Updates" = "Azure Update Manager"
        }

        if ($window -eq "Saturday") {
            $tags["PatchWindow"] = "sat-09"  # Saturday 09:00 for dev/test
        } else {
            $tags["PatchWindow"] = $window.ToLower().Substring(0,3)  # mon, tue, wed, etc.
        }

        $result = Set-VMMaintenanceTags -VMName $vmInfo.Name -ResourceGroupName $vmInfo.RG -SubscriptionId $subscriptionId -Tags $tags -MaintenanceWindow $window

        $totalProcessed++
        if ($result) { 
            $totalSuccess++
            $newAssignments[$window]++
        } else { 
            $totalFailed++ 
        }
    }
}

Write-Host ""
Write-Host "=== TAGGING SUMMARY ===" -ForegroundColor Cyan
Write-Host "Total VMs processed: $totalProcessed" -ForegroundColor White
Write-Host "Successfully tagged: $totalSuccess" -ForegroundColor Green
Write-Host "Failed to tag: $totalFailed" -ForegroundColor Red

Write-Host ""
Write-Host "=== NEW MAINTENANCE WINDOW DISTRIBUTION ===" -ForegroundColor Cyan
Write-Host "VMs added to each maintenance window:" -ForegroundColor White

$newAssignments.GetEnumerator() | Sort-Object Name | ForEach-Object {
    if ($_.Value -gt 0) {
        $newTotal = $currentLoad[$_.Key] + $_.Value
        Write-Host "$($_.Key): +$($_.Value) VMs (total: $newTotal VMs)" -ForegroundColor Green
    }
}

Write-Host ""
Write-Host "=== FINAL MAINTENANCE WINDOW LOAD ===" -ForegroundColor Cyan
$finalLoad = @{}
$currentLoad.Keys | ForEach-Object {
    $finalLoad[$_] = $currentLoad[$_] + $newAssignments[$_]
}

$finalLoad.GetEnumerator() | Sort-Object Name | ForEach-Object {
    $status = if ($_.Value -le 8) { "Green" } elseif ($_.Value -le 12) { "Yellow" } else { "Red" }
    Write-Host "$($_.Key): $($_.Value) VMs" -ForegroundColor $status
}

$grandTotal = ($finalLoad.Values | Measure-Object -Sum).Sum
Write-Host "`nGrand Total: $grandTotal VMs across all maintenance windows" -ForegroundColor White

Write-Host ""
Write-Host "=== BUSINESS LOGIC APPLIED ===" -ForegroundColor Cyan
Write-Host "βœ… Critical systems spread across different days for resilience" -ForegroundColor Green
Write-Host "βœ… Domain Controllers distributed to avoid single points of failure" -ForegroundColor Green
Write-Host "βœ… Dev/Test systems consolidated to Saturday morning (existing pattern)" -ForegroundColor Green
Write-Host "βœ… Production workstations spread to minimize user impact" -ForegroundColor Green
Write-Host "βœ… Business applications distributed for operational continuity" -ForegroundColor Green
Write-Host "βœ… Load balancing maintained across the week" -ForegroundColor Green

Write-Host ""
Write-Host "=== VERIFICATION STEPS ===" -ForegroundColor Cyan
Write-Host "1. Verify tags in Azure Portal across all subscriptions" -ForegroundColor White
Write-Host "2. Check that critical systems are on different days" -ForegroundColor White
Write-Host "3. Confirm dev/test systems are in Saturday morning window" -ForegroundColor White
Write-Host "4. Review production systems distribution" -ForegroundColor White

Write-Host ""
Write-Host "=== AZURE RESOURCE GRAPH VERIFICATION QUERY ===" -ForegroundColor Cyan
Write-Host "Use this query to verify all VMs are now tagged:" -ForegroundColor White
Write-Host ""
Write-Host @"
Resources
| where type == "microsoft.compute/virtualmachines"
| where tags.Updates == "Azure Update Manager"
| project name, resourceGroup, subscriptionId, 
          patchWindow = tags.PatchWindow,
          owner = tags.Owner,
          updates = tags.Updates
| sort by patchWindow, name
| summarize count() by patchWindow
"@ -ForegroundColor Gray

if ($totalFailed -eq 0) {
    Write-Host ""
    Write-Host "οΏ½ ALL VMs SUCCESSFULLY TAGGED WITH INTELLIGENT DISTRIBUTION! οΏ½" -ForegroundColor Green
} else {
    Write-Host ""
    Write-Host "⚠️ Some VMs failed to tag. Please review errors above." -ForegroundColor Yellow
}

Write-Host ""
Write-Host "Script completed at $(Get-Date)" -ForegroundColor Cyan
Write-Host "Total runtime: $((Get-Date) - $scriptStart)" -ForegroundColor Gray

Key insight: I grouped VMs by function and criticality, not just by convenience. Domain controllers got spread across different days, dev/test systems joined the existing Saturday morning window, and production applications were distributed for business continuity.


🧰 Step 7 – Configure Azure Policy Prerequisites

Here's where things get interesting. Update Manager is built on compliance β€” but your VMs won't show up in dynamic scopes unless they meet certain prerequisites. Enter Azure Policy to save the day.

You'll need two specific built-in policies assigned at the subscription (or management group) level:

βœ… Policy 1: Set prerequisites for scheduling recurring updates on Azure virtual machines

What it does: This policy ensures your VMs have the necessary configurations to participate in Azure Update Manager. It automatically:

  • Installs the Azure Update Manager extension on Windows VMs
  • Registers required resource providers
  • Configures the VM to report its update compliance status
  • Sets the patch orchestration mode appropriately

Why this matters: Without this policy, VMs won't appear in Update Manager scopes even if they're tagged correctly. The policy handles all the "plumbing" automatically.

Assignment scope: Apply this at subscription or management group level to catch all VMs.

βœ… Policy 2: Configure periodic checking for missing system updates on Azure virtual machines

What it does: This is your compliance engine. It configures VMs to:

  • Regularly scan for available updates (but not install them automatically)
  • Report update status back to Azure Update Manager
  • Enable the compliance dashboard views in the portal
  • Provide the data needed for maintenance configuration targeting

Why this matters: This policy turns on the "update awareness" for your VMs. Without it, Azure Update Manager has no visibility into what patches are needed.

Assignment scope: Same as above β€” subscription or management group level.

🎯 Assigning the Policies

Step-by-step in Azure Portal:

  1. Navigate to Azure Policy
  2. Azure Portal β†’ Search "Policy" β†’ Select "Policy"

  3. Find the First Policy

  4. Left menu: Definitions
  5. Search: Set prerequisites for scheduling recurring updates
  6. Click on the policy title

  7. Assign the Policy

  8. Click Assign button
  9. Scope: Select your subscription(s)
  10. Basics: Leave policy name as default
  11. Parameters: Leave as default
  12. Remediation: βœ… Check "Create remediation task"
  13. Review + create

  14. Repeat for Second Policy

  15. Search: Configure periodic checking for missing system updates
  16. Follow same assignment process

⚠️ Important: Policy compliance can take 30+ minutes to evaluate and apply. Perfect time for that brew I mentioned earlier.

πŸ” Monitoring Compliance

Once assigned, you can track compliance in Azure Policy > Compliance. Look for:

  • Non-compliant VMs that need the extension installed
  • VMs that aren't reporting update status properly
  • Any policy assignment errors that need investigation

Learn more about Azure Policy for Update Management


πŸ§ͺ Step 8 – Create Dynamic Scopes in Update Manager

This is where it all comes together β€” and where the magic happens.

Dynamic scopes use those PatchWindow tags to assign VMs to the correct patch config automatically. No more manual VM assignment, no more "did we remember to add the new server?" conversations.

🎯 The Portal Dance

Unfortunately, as of writing, dynamic scopes can only be configured through the Azure portal β€” no PowerShell or ARM template support yet.

Why portal only? Dynamic scopes are still in preview, and Microsoft hasn't released the PowerShell cmdlets or ARM template schemas yet. This means you can't fully automate the deployment, but the functionality itself works perfectly.

Here's the step-by-step:

  1. Navigate to Azure Update Manager
  2. Portal β†’ All Services β†’ Azure Update Manager

  3. Access Maintenance Configurations

  4. Go to Maintenance Configurations (Preview)
  5. Select one of your configs (e.g., contoso-maintenance-config-vms-mon)

  6. Create Dynamic Scope

  7. Click Dynamic Scopes β†’ Add
  8. Name: DynamicScope-Monday-VMs
  9. Description: Auto-assign Windows VMs tagged for Monday maintenance

  10. Configure Scope Settings

  11. Subscription: Select your subscription(s)
  12. Resource Type: Microsoft.Compute/virtualMachines
  13. OS Type: Windows (create separate scopes for Linux if needed)

  14. Set Tag Filters

  15. Tag Name: PatchWindow
  16. Tag Value: mon (must match your maintenance config naming)
  17. Additional filters (optional):

    • Owner = Contoso
    • Updates = Azure Update Manager
  18. Review and Create

  19. Verify the filter logic
  20. Click Create

πŸ”„ Repeat for All Days

You'll need to create dynamic scopes for each maintenance configuration:

Maintenance Config Dynamic Scope Name Tag Filter
contoso-maintenance-config-vms-mon DynamicScope-Monday-VMs PatchWindow = mon
contoso-maintenance-config-vms-tue DynamicScope-Tuesday-VMs PatchWindow = tue
contoso-maintenance-config-vms-wed DynamicScope-Wednesday-VMs PatchWindow = wed
contoso-maintenance-config-vms-thu DynamicScope-Thursday-VMs PatchWindow = thu
contoso-maintenance-config-vms-fri DynamicScope-Friday-VMs PatchWindow = fri
contoso-maintenance-config-vms-sat DynamicScope-Saturday-VMs PatchWindow = sat-09
contoso-maintenance-config-vms-sun DynamicScope-Sunday-VMs PatchWindow = sun

πŸ” Verify Dynamic Scope Assignment

Once created, you can verify the scopes are working:

  1. In the Maintenance Configuration:
  2. Go to Dynamic Scopes
  3. Check Resources tab to see matched VMs
  4. Verify expected VM count matches your tagging
  5. Wait time: Allow 15-30 minutes for newly tagged VMs to appear

  6. What success looks like:

  7. Monday scope shows 5 VMs (WEB-PROD-01, DB-PROD-01, etc.)
  8. Saturday scope shows 5 VMs (WEB-DEV-01, DB-DEV-01, etc.)
  9. No VMs showing? Check tag case sensitivity and filters

  10. In Azure Resource Graph:

MaintenanceResources
| where type == "microsoft.maintenance/configurationassignments"
| extend vmName = tostring(split(resourceId, "/")[8])
| extend configName = tostring(properties.maintenanceConfigurationId)
| project vmName, configName, resourceGroup
| order by configName, vmName
  1. Troubleshoot empty scopes:
  2. Verify subscription selection includes all your VMs
  3. Check tag spelling: PatchWindow (case sensitive)
  4. Confirm resource type filter: Microsoft.Compute/virtualMachines
  5. Wait longer - it can take up to 30 minutes

⚠️ Common Gotchas

Tag Case Sensitivity: Dynamic scopes are case-sensitive. mon β‰  Mon β‰  MON

Subscription Scope: Ensure you've selected all relevant subscriptions in the scope configuration.

Resource Type Filter: Don't forget to set the resource type filter β€” without it, you'll match storage accounts, networking, etc.

Timing: It can take 15-30 minutes for newly tagged VMs to appear in dynamic scopes.

Dynamic scope configuration docs


πŸš€ Step 9 – Test & Verify (The Moment of Truth)

The acid test: does it actually patch stuff properly?

πŸŽͺ Proof of Concept Test

I started conservatively β€” scoped contoso-maintenance-config-vms-sun to a few non-critical VMs and let it run overnight on Sunday.

Monday morning verification:

  • βœ”οΈ Patch compliance dashboard: All green ticks
  • βœ”οΈ Reboot timing: Machines restarted within their 4-hour window (21:00-01:00)
  • βœ”οΈ Update logs: Activity logs showed expected patching behavior
  • βœ”οΈ Business impact: Zero helpdesk tickets on Monday morning

πŸ“Š Full Rollout Verification

Once confident with the Sunday test, I enabled all remaining dynamic scopes and monitored the week:

Key metrics tracked:

  • Patch compliance percentage across all VMs
  • Failed patch installations (and root causes)
  • Reboot timing adherence
  • Business hours impact (spoiler: zero)

πŸ” Monitoring & Validation Tools

Azure Update Manager Dashboard:

Azure Portal β†’ Update Manager β†’ Overview
- Patch compliance summary
- Recent patch installations
- Failed installations with details

Azure Resource Graph Queries:

// Verify all VMs have maintenance tags
Resources
| where type == "microsoft.compute/virtualmachines"
| where tags.Updates == "Azure Update Manager"
| project name, resourceGroup, subscriptionId, 
          patchWindow = tags.PatchWindow,
          owner = tags.Owner
| summarize count() by patchWindow
| order by patchWindow

// Check maintenance configuration assignments
MaintenanceResources
| where type == "microsoft.maintenance/configurationassignments"
| extend vmName = tostring(split(resourceId, "/")[8])
| extend configName = tostring(properties.maintenanceConfigurationId)
| project vmName, configName, subscriptionId
| summarize VMCount = count() by configName
| order by configName

PowerShell Verification:

# Quick check of maintenance configuration status
Get-AzMaintenanceConfiguration -ResourceGroupName "rg-maintenance-uksouth-001" | 
    Select-Object Name, MaintenanceScope, RecurEvery | 
    Format-Table -AutoSize

# Verify VM tag distribution
$subscriptions = Get-AzSubscription | Where-Object { $_.State -eq "Enabled" }
$tagSummary = @{}

foreach ($sub in $subscriptions) {
    Set-AzContext -SubscriptionId $sub.Id | Out-Null
    $vms = Get-AzVM | Where-Object { $_.Tags.PatchWindow }

    foreach ($vm in $vms) {
        $window = $vm.Tags.PatchWindow
        if (-not $tagSummary.ContainsKey($window)) {
            $tagSummary[$window] = 0
        }
        $tagSummary[$window]++
    }
}

Write-Host "=== VM DISTRIBUTION BY PATCH WINDOW ===" -ForegroundColor Cyan
$tagSummary.GetEnumerator() | Sort-Object Name | ForEach-Object {
    Write-Host "$($_.Key): $($_.Value) VMs" -ForegroundColor White
}

πŸ“ˆ Success Metrics

After two full weeks of operation:

  • Better control: Direct management of patch schedules and policies
  • Increased visibility: Real-time compliance dashboards vs. periodic reports
  • Reduced complexity: Native Azure tooling vs. third-party solutions

Monitor updates in Azure Update Manager


πŸ“ƒ Final Thoughts & Tips

βœ… Cost-neutral β€” No more third-party patch agents βœ… Policy-driven β€” Enforced consistency with Azure Policy βœ… Easily auditable β€” Tag-based scoping is clean and visible βœ… Scalable β€” New VMs auto-join patch schedules via tagging

⚠️ Troubleshooting Guide & Common Issues

Here's what I learned the hard way, so you don't have to:

Symptom Possible Cause Fix
VM not showing in dynamic scope Tag typo or case mismatch Verify PatchWindow tag exactly matches config name
Maintenance config creation fails Invalid duration format Use ISO 8601 format: "03:00" not "3 hours"
VM skipped during patching Policy prerequisites not met Check Azure Policy compliance dashboard
No updates applied despite schedule VM needs pending reboot Clear previous reboots, check update history
Dynamic scope shows zero VMs Wrong subscription scope Verify subscription selection in scope config
Extension installation failed Insufficient permissions Ensure VM contributor rights and resource provider registration
Policy compliance stuck at 0% Assignment scope too narrow Check policy is assigned at subscription level
VMs appear/disappear from scope Tag inconsistency Run tag verification script across all subscriptions

πŸ”§ Advanced Troubleshooting Commands

Check VM Update Readiness:

# Verify VM has required extensions and configuration
$vmName = "your-vm-name"
$rgName = "your-resource-group"

$vm = Get-AzVM -Name $vmName -ResourceGroupName $rgName -Status
$vm.Extensions | Where-Object { $_.Name -like "*Update*" -or $_.Name -like "*Maintenance*" }

Validate Maintenance Configuration:

# Test maintenance configuration is properly formed
$config = Get-AzMaintenanceConfiguration -ResourceGroupName "rg-maintenance-uksouth-001" -Name "contoso-maintenance-config-vms-mon"
Write-Host "Config Name: $($config.Name)"
Write-Host "Recurrence: $($config.RecurEvery)"
Write-Host "Duration: $($config.Duration)"
Write-Host "Start Time: $($config.StartDateTime)"
Write-Host "Timezone: $($config.TimeZone)"

Policy Compliance Deep Dive:

# Check specific VMs for policy compliance
$policyName = "Set prerequisites for scheduling recurring updates on Azure virtual machines"
$assignments = Get-AzPolicyAssignment | Where-Object { $_.Properties.DisplayName -eq $policyName }
foreach ($assignment in $assignments) {
    Get-AzPolicyState -PolicyAssignmentId $assignment.PolicyAssignmentId | 
        Where-Object { $_.ComplianceState -eq "NonCompliant" } |
        Select-Object ResourceId, ComplianceState, @{Name="Reason";Expression={$_.PolicyEvaluationDetails.EvaluatedExpressions.ExpressionValue}}
}

As always, comments and suggestions welcome over on GitHub or LinkedIn. If you've migrated patching in a different way, I'd love to hear how you approached it.

Share on Share on