~/2025/10/15/powershell-hyper-v-automate-vm-checkpoints-before-patch-tuesday.md

PowerShell: Hyper-V – Automate VM Checkpoints Before Patch Tuesday

---
author: 
date: 
read: 4 min
in:   [ps, scripts]
tags: [powershell, hyper-v, cluster]
---

$ grep -n '^#' post.md

Patch Tuesday should be boring. The way I make it boring on Hyper-V is to checkpoint every running VM right before the update window opens, so a bad driver or a broken service after reboot is a two-minute Restore-VMSnapshot instead of a restore-from-backup ticket. This script takes the checkpoint, names it with a prefix and the date so it is easy to find, prunes the checkpoints its own earlier runs left behind, and works against a single host, a list of hosts or every node of a failover cluster. Microsoft publishes the monthly security release on the second Tuesday of each month (10:00 AM Pacific), so the script can also refuse to run outside that week.

Requirements

  • Windows PowerShell 5.1 on a host or management machine with the Hyper-V PowerShell module (Hyper-V-PowerShell feature). Add RSAT-Clustering-PowerShell if you use -ClusterName.
  • Hyper-V Administrators (or local Administrators) on every target host.
  • VMs at configuration version 6.2 or later for production checkpoints, and a guest whose Volume Shadow Copy Service (Windows) or file system freeze (Linux) works, since that is what makes the checkpoint data consistent.
  • Free space on each VM's volume or CSV for the differencing disk (.avhdx) to grow during the patch window, and again for the merge when the checkpoint is deleted.

Parameters

NameTypeRequiredDescription
VMNamestringNoNames of VMs to checkpoint. Defaults to every running VM on the target hosts.
ComputerNamestringNoHyper-V hosts to target. Defaults to the local computer. Ignored when ClusterName is set.
ClusterNamestringNoFailover cluster whose nodes (those that are Up) are all targeted.
RetentionDaysintNoCheckpoints created by this script and older than this many days are removed before new ones are taken. Defaults to 7.
CheckpointPrefixstringNoPrefix used to name and identify this script's checkpoints, so cleanup only ever touches its own. Defaults to PatchTuesday.
RequirePatchWeekswitchNoExit without doing anything unless today falls in the seven days starting on this month's second Tuesday.

The script also honours -WhatIf and -Verbose.

Usage

Checkpoint every running VM on the local host with the default seven-day retention, showing what it does.

powershell
.\New-PrePatchCheckpoint.ps1 -Verbose

Checkpoint three VMs on a cluster and keep the checkpoints for only two days.

powershell
.\New-PrePatchCheckpoint.ps1 -ClusterName 'hv-clu01' -VMName 'app-svr01', 'app-svr02', 'sql-svr01' -RetentionDays 2

The script returns one object per action, so the output doubles as a change record.

text
VM         Host       Action   Checkpoint              CheckpointType
--         ----       ------   ----------              --------------
app-svr01  HV-NODE01  Removed  PatchTuesday-2025-09-09 Production
app-svr01  HV-NODE01  Created  PatchTuesday-2025-10-15 Production
app-svr02  HV-NODE02  Created  PatchTuesday-2025-10-15 Production
sql-svr01  HV-NODE02  Failed   PatchTuesday-2025-10-15 ProductionOnly

Preview a cluster-wide run without changing anything.

powershell
.\New-PrePatchCheckpoint.ps1 -ClusterName 'hv-clu01' -WhatIf

Run it from Cluster-Aware Updating, so each node checkpoints its own VMs just before CAU drains and patches it. The pre-update script must be a .ps1 reachable from every node (a CSV or a highly available share), and PowerShell remoting must be enabled on each node.

powershell
$parameters = @{
    ClusterName           = 'hv-clu01'
    CauPluginName         = 'Microsoft.WindowsUpdatePlugin'
    PreUpdateScript       = 'C:\ClusterStorage\Volume1\Scripts\New-PrePatchCheckpoint.ps1'
    MaxFailedNodes        = 1
    MaxRetriesPerNode     = 3
    RequireAllNodesOnline = $true
    Force                 = $true
}
Invoke-CauRun @parameters

Script

powershell
<#
.SYNOPSIS
    Creates a dated pre-patch checkpoint on running Hyper-V VMs and prunes the ones earlier runs created.
.DESCRIPTION
    Finds the target VMs (every running VM on the local host, on a list of hosts, or on every Up node of a
    failover cluster, optionally filtered by name), removes this script's own checkpoints older than the
    retention window, then takes a checkpoint named <prefix>-<yyyy-MM-dd> using each VM's configured
    checkpoint type. Returns one object per action. A failure on one VM is reported and the run continues,
    so the script is safe to use as a Cluster-Aware Updating pre-update script.
.PARAMETER VMName
    Names of VMs to checkpoint. Defaults to every running VM on the target hosts.
.PARAMETER ComputerName
    Hyper-V hosts to target. Defaults to the local computer. Ignored when ClusterName is set.
.PARAMETER ClusterName
    Failover cluster whose nodes (those that are Up) are all targeted.
.PARAMETER RetentionDays
    Checkpoints created by this script and older than this many days are removed before new ones are taken.
.PARAMETER CheckpointPrefix
    Prefix used to name and identify this script's checkpoints.
.PARAMETER RequirePatchWeek
    Exit unless today falls in the seven days starting on this month's second Tuesday.
.EXAMPLE
    .\New-PrePatchCheckpoint.ps1 -ClusterName 'hv-clu01' -VMName 'app-svr01', 'app-svr02' -RetentionDays 2
.NOTES
    Author  : Thomas Lasswell (https://www.techcolumnist.com)
    Version : 1.0 (2025-10-15)
    Requires: Hyper-V PowerShell module; FailoverClusters module for -ClusterName
#>

[CmdletBinding(SupportsShouldProcess = $true)]
param (
    [Parameter(Mandatory = $false)]
    [string[]]$VMName,

    [Parameter(Mandatory = $false)]
    [string[]]$ComputerName = $env:COMPUTERNAME,

    [Parameter(Mandatory = $false)]
    [string]$ClusterName,

    [Parameter(Mandatory = $false)]
    [ValidateRange(0, 365)]
    [int]$RetentionDays = 7,

    [Parameter(Mandatory = $false)]
    [string]$CheckpointPrefix = 'PatchTuesday',

    [switch]$RequirePatchWeek
)

function Get-PatchTuesday {
    param (
        [datetime]$Date
    )

    # Second Tuesday of the month: find the first Tuesday, then add a week.
    $firstOfMonth = [datetime]::new($Date.Year, $Date.Month, 1)
    $daysToTuesday = ([int][System.DayOfWeek]::Tuesday - [int]$firstOfMonth.DayOfWeek + 7) % 7

    return $firstOfMonth.AddDays($daysToTuesday + 7)
}

$now = Get-Date

if ($RequirePatchWeek) {
    $patchTuesday = Get-PatchTuesday -Date $now

    if ($now.Date -lt $patchTuesday -or $now.Date -ge $patchTuesday.AddDays(7)) {
        Write-Verbose "Today is outside the patch week that starts $($patchTuesday.ToString('yyyy-MM-dd')); nothing to do"
        return
    }
}

# Work out which hosts to query.
$targetHosts = if ($ClusterName) {
    (Get-ClusterNode -Cluster $ClusterName | Where-Object { $_.State -eq 'Up' }).Name
} else {
    $ComputerName
}

# Get-VM with no host falls back to the local computer, so stop rather than checkpoint the wrong machine.
if ($ClusterName -and -not $targetHosts) {
    throw "No nodes of cluster $ClusterName are Up."
}

$allVMs = Get-VM -ComputerName $targetHosts

$targetVMs = if ($VMName) {
    $allVMs | Where-Object { $VMName -contains $_.Name }
} else {
    $allVMs | Where-Object { $_.State -eq 'Running' }
}

foreach ($name in $VMName) {
    if ($targetVMs.Name -notcontains $name) {
        Write-Warning "VM '$name' was not found on $($targetHosts -join ', ')"
    }
}

$checkpointName = '{0}-{1:yyyy-MM-dd}' -f $CheckpointPrefix, $now
$cutoffDate = $now.AddDays(-$RetentionDays)

foreach ($vm in $targetVMs) {
    if ("$($vm.CheckpointType)" -eq 'Disabled') {
        Write-Warning "Checkpoints are disabled on $($vm.Name), skipping"
        continue
    }

    if ("$($vm.CheckpointType)" -eq 'Standard') {
        Write-Warning "$($vm.Name) uses standard checkpoints, which capture memory state and are not application consistent"
    }

    # Remove this script's own expired checkpoints before creating a new one.
    $oldCheckpoints = Get-VMSnapshot -VM $vm |
        Where-Object { $_.Name -like "$CheckpointPrefix-*" -and $_.CreationTime -lt $cutoffDate }

    foreach ($old in $oldCheckpoints) {
        if ($PSCmdlet.ShouldProcess("$($vm.Name) on $($vm.ComputerName)", "Remove checkpoint '$($old.Name)'")) {
            Write-Verbose "Removing expired checkpoint '$($old.Name)' on $($vm.Name)"
            $removeAction = 'Removed'

            try {
                Remove-VMSnapshot -VMSnapshot $old -ErrorAction Stop
            } catch {
                Write-Warning "Removing checkpoint '$($old.Name)' from $($vm.Name) failed: $($_.Exception.Message)"
                $removeAction = 'Failed'
            }

            [PSCustomObject]@{
                VM             = $vm.Name
                Host           = $vm.ComputerName
                Action         = $removeAction
                Checkpoint     = $old.Name
                CheckpointType = "$($vm.CheckpointType)"
            }
        }
    }

    $existing = Get-VMSnapshot -VM $vm -Name $checkpointName -ErrorAction SilentlyContinue

    if ($existing) {
        Write-Verbose "Checkpoint '$checkpointName' already exists on $($vm.Name), skipping"
        continue
    }

    if ($PSCmdlet.ShouldProcess("$($vm.Name) on $($vm.ComputerName)", "Create checkpoint '$checkpointName'")) {
        Write-Verbose "Creating checkpoint '$checkpointName' on $($vm.Name)"
        $action = 'Created'

        try {
            Checkpoint-VM -VM $vm -SnapshotName $checkpointName -ErrorAction Stop
        } catch {
            Write-Warning "Checkpoint of $($vm.Name) failed: $($_.Exception.Message)"
            $action = 'Failed'
        }

        [PSCustomObject]@{
            VM             = $vm.Name
            Host           = $vm.ComputerName
            Action         = $action
            Checkpoint     = $checkpointName
            CheckpointType = "$($vm.CheckpointType)"
        }
    }
}

Notes

  • Checkpoints are not backups. Microsoft's own FAQ answers "Should checkpoints be used as a substitute for backups?" with a flat no: a checkpoint is not the same as a VSS writer backup and is not recommended as a permanent data or system recovery solution. It lives on the same storage as the VM, so it protects you against a bad patch for the day or two it takes to confirm the update went well, and does nothing for storage failure or ransomware. Keep your real backup schedule regardless.
  • The checkpoint type comes from the VM, not the script. Production (the default for new VMs) uses VSS in Windows guests or file system freeze in Linux guests and falls back to a standard checkpoint if that fails; ProductionOnly fails instead of falling back, which is why sql-svr01 shows Failed in the sample above. Change it with Set-VM -Name <vmname> -CheckpointType ProductionOnly if you would rather have a failure than a memory-state checkpoint. Microsoft warns that standard checkpoints can cause consistency problems for anything that replicates between nodes, Active Directory being the example it gives.
  • Keep the retention short. Microsoft notes that the presence of a checkpoint reduces the VM's disk performance, and a differencing disk on a write-heavy SQL Server or Exchange VM grows for as long as it exists. I run RetentionDays at 1 or 2 on those hosts so Remove-VMSnapshot merges the .avhdx back before the next cycle. A merge needs free space; if it fails with 0x80070070, free space or export the VM to a larger volume. Never delete .avhdx files by hand.
  • Rolling back a production checkpoint leaves the VM off (there is no memory state to resume), so plan for a guest boot: Restore-VMSnapshot -VMName <vmname> -Name <checkpoint> -Confirm:$false, then Start-VM -Name <vmname>.
  • Off VMs are skipped by default because nothing changes inside them until they start, so there is nothing for a patch to break. Name them in -VMName if you want a checkpoint anyway; Checkpoint-VM works on an off VM.
  • As a CAU pre-update script, a script that fails stops that node from being updated. That is why the checkpoint and cleanup calls are wrapped in try/catch: one VM whose VSS writer misbehaves, or whose old checkpoint won't merge, produces a warning and a Failed row instead of blocking the whole Updating Run. Get-VMSnapshot, Checkpoint-VM and Remove-VMSnapshot are the real cmdlet names; Get-VMCheckpoint and friends are aliases the Hyper-V module adds, so they are avoided here.

Source