# Daily VM Snapshot Backup Script - Checkpoint-based Incremental Backup # Run Daily at 3am # Location: Hyper-V Server # Creates daily checkpoints and backs up only the differencing data param( [Parameter(ParameterSetName='SingleVM', Mandatory=$true)] [string]$VMName, [Parameter(ParameterSetName='AllVMs', Mandatory=$true)] [switch]$AllVMs, [Parameter(ParameterSetName='AllVMs')] [string[]]$ExcludeVMs = @(), [int]$MaxSnapshots = 7, # Keep 7 days of snapshots [switch]$BackupSnapshotFiles, # Whether to also backup snapshot files to NAS [string]$SnapshotPrefix = "Daily" ) # Configuration - Modify these paths as needed $NASBackupPath = "\\nas5362\Backups\Daily" $LocalSnapshotBackupPath = "F:\Backups\Daily" # For snapshot file backups $SevenZipPath = "C:\Program Files\7-Zip\7z.exe" # Configure logging $LogPath = "C:\Scripts\Logs\Daily_Snapshot_$(Get-Date -Format 'yyyyMM').log" $null = New-Item -ItemType Directory -Path (Split-Path $LogPath -Parent) -Force -ErrorAction SilentlyContinue function Write-Log { param([string]$Message, [string]$Level = "INFO") $TimeStamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $LogEntry = "[$TimeStamp] [$Level] $Message" Write-Host $LogEntry Add-Content -Path $LogPath -Value $LogEntry } function Test-NetworkPath { param([string]$Path) try { if (-not (Test-Path $Path -ErrorAction Stop)) { Write-Log "Network path does not exist: $Path" "ERROR" return $false } $TestFile = Join-Path $Path "test_write_$(Get-Date -Format 'yyyyMMddHHmmss').tmp" try { $null = New-Item -ItemType File -Path $TestFile -Force -ErrorAction Stop Remove-Item -Path $TestFile -Force -ErrorAction SilentlyContinue Write-Log "Network path is accessible and writable: $Path" return $true } catch { Write-Log "Network path exists but is not writable: $Path - $_" "ERROR" return $false } } catch { Write-Log "Network path test failed: $Path - $_" "ERROR" return $false } } function Get-VMSnapshotFiles { param([string]$VMName) try { $VM = Get-VM -Name $VMName $CheckpointFiles = @() # Get all checkpoints for this VM $Checkpoints = Get-VMCheckpoint -VMName $VMName | Sort-Object CreationTime -Descending foreach ($Checkpoint in $Checkpoints) { # Get AVHDX files (differencing disks) for this checkpoint $CheckpointVHDs = Get-VMCheckpoint -VMName $VMName -Name $Checkpoint.Name | Get-VMHardDiskDrive foreach ($VHD in $CheckpointVHDs) { if ($VHD.Path -match "\.avhdx$") { $CheckpointFiles += [PSCustomObject]@{ CheckpointName = $Checkpoint.Name CheckpointId = $Checkpoint.Id CreationTime = $Checkpoint.CreationTime FilePath = $VHD.Path FileType = "AVHDX" } } } # Also get the checkpoint configuration files $VMPath = Split-Path $VM.ConfigurationLocation -Parent $CheckpointConfigPath = Join-Path $VMPath "Snapshots\$($Checkpoint.Id.Guid).vmrs" if (Test-Path $CheckpointConfigPath) { $CheckpointFiles += [PSCustomObject]@{ CheckpointName = $Checkpoint.Name CheckpointId = $Checkpoint.Id CreationTime = $Checkpoint.CreationTime FilePath = $CheckpointConfigPath FileType = "VMRS" } } } return $CheckpointFiles } catch { Write-Log "Error getting snapshot files for $VMName : $_" "ERROR" return @() } } function Backup-SnapshotFiles { param( [string]$VMName, [array]$SnapshotFiles, [string]$BackupPath ) try { Write-Log "Backing up snapshot files for $VMName..." # Create backup directory $VMBackupPath = Join-Path $BackupPath $VMName $DateBackupPath = Join-Path $VMBackupPath (Get-Date -Format 'yyyyMMdd') $null = New-Item -ItemType Directory -Path $DateBackupPath -Force $TotalSize = 0 $BackedUpFiles = 0 foreach ($File in $SnapshotFiles) { try { if (Test-Path $File.FilePath) { $FileName = Split-Path $File.FilePath -Leaf $BackupFilePath = Join-Path $DateBackupPath $FileName # Only backup if file doesn't exist or is newer $ShouldCopy = $true if (Test-Path $BackupFilePath) { $SourceDate = (Get-Item $File.FilePath).LastWriteTime $BackupDate = (Get-Item $BackupFilePath).LastWriteTime $ShouldCopy = $SourceDate -gt $BackupDate } if ($ShouldCopy) { Copy-Item -Path $File.FilePath -Destination $BackupFilePath -Force $FileSize = (Get-Item $File.FilePath).Length $TotalSize += $FileSize $BackedUpFiles++ $FileSizeMB = [math]::Round($FileSize / 1MB, 2) Write-Log "Backed up $FileName ($FileSizeMB MB)" } } } catch { Write-Log "Failed to backup $($File.FilePath): $_" "ERROR" } } $TotalSizeGB = [math]::Round($TotalSize / 1GB, 2) Write-Log "Snapshot file backup completed for $VMName. Files: $BackedUpFiles, Size: $TotalSizeGB GB" return $true } catch { Write-Log "Error backing up snapshot files for $VMName : $_" "ERROR" return $false } } function Cleanup-OldSnapshots { param( [string]$VMName, [int]$MaxSnapshots, [string]$SnapshotPrefix ) try { Write-Log "Cleaning up old snapshots for $VMName (keeping $MaxSnapshots)..." # Get all snapshots with our prefix, sorted by creation time $Snapshots = Get-VMCheckpoint -VMName $VMName | Where-Object { $_.Name -like "$SnapshotPrefix*" } | Sort-Object CreationTime -Descending if ($Snapshots.Count -gt $MaxSnapshots) { $ToDelete = $Snapshots | Select-Object -Skip $MaxSnapshots foreach ($Snapshot in $ToDelete) { Write-Log "Removing old snapshot: $($Snapshot.Name) (Created: $($Snapshot.CreationTime))" Remove-VMCheckpoint -VMName $VMName -Name $Snapshot.Name -IncludeAllChildCheckpoints } Write-Log "Removed $($ToDelete.Count) old snapshots for $VMName" } else { Write-Log "No cleanup needed for $VMName (has $($Snapshots.Count) snapshots)" } } catch { Write-Log "Error cleaning up snapshots for $VMName : $_" "ERROR" } } function Cleanup-OldSnapshotBackups { param( [string]$BackupPath, [string]$VMName, [int]$MaxBackups ) try { $VMBackupPath = Join-Path $BackupPath $VMName if (-not (Test-Path $VMBackupPath)) { return } # Get backup folders (by date) $BackupFolders = Get-ChildItem -Path $VMBackupPath -Directory | Where-Object { $_.Name -match '^\d{8}$' } | Sort-Object Name -Descending if ($BackupFolders.Count -gt $MaxBackups) { $ToDelete = $BackupFolders | Select-Object -Skip $MaxBackups foreach ($Folder in $ToDelete) { Write-Log "Removing old snapshot backup folder: $($Folder.FullName)" Remove-Item -Path $Folder.FullName -Recurse -Force } Write-Log "Removed $($ToDelete.Count) old snapshot backup folders for $VMName" } } catch { Write-Log "Error cleaning up old snapshot backups for $VMName : $_" "ERROR" } } function Create-DailySnapshot { param( [string]$VMName, [int]$MaxSnapshots, [string]$SnapshotPrefix ) try { Write-Log "=== Creating daily snapshot for VM: $VMName ===" # Verify VM exists and is running $VM = Get-VM -Name $VMName -ErrorAction Stop Write-Log "VM found: $($VM.Name) - State: $($VM.State)" if ($VM.State -ne 'Running') { Write-Log "VM is not running, skipping snapshot creation" "WARNING" return $false } # Create snapshot name with timestamp $SnapshotName = "$SnapshotPrefix_$(Get-Date -Format 'yyyyMMdd_HHmm')" Write-Log "Creating snapshot: $SnapshotName" $StartTime = Get-Date # Create the checkpoint Checkpoint-VM -Name $VMName -SnapshotName $SnapshotName $EndTime = Get-Date $Duration = ($EndTime - $StartTime).TotalSeconds Write-Log "Snapshot created successfully in $Duration seconds" # Get the new snapshot info $NewSnapshot = Get-VMCheckpoint -VMName $VMName -Name $SnapshotName if ($NewSnapshot) { Write-Log "Snapshot ID: $($NewSnapshot.Id)" Write-Log "Snapshot Path: $($NewSnapshot.Path)" # Get size of new differencing files $SnapshotFiles = Get-VMSnapshotFiles -VMName $VMName | Where-Object { $_.CheckpointName -eq $SnapshotName } $TotalSnapshotSize = 0 foreach ($File in $SnapshotFiles) { if (Test-Path $File.FilePath) { $FileSize = (Get-Item $File.FilePath).Length $TotalSnapshotSize += $FileSize } } $SnapshotSizeMB = [math]::Round($TotalSnapshotSize / 1MB, 2) Write-Log "New snapshot differencing files size: $SnapshotSizeMB MB" } # Clean up old snapshots Cleanup-OldSnapshots -VMName $VMName -MaxSnapshots $MaxSnapshots -SnapshotPrefix $SnapshotPrefix Write-Log "=== Daily snapshot completed successfully for $VMName ===" return $true } catch { Write-Log "=== Snapshot creation failed for $VMName : $_ ===" "ERROR" return $false } } function Process-SingleVM { param( [string]$VMName, [int]$MaxSnapshots, [string]$SnapshotPrefix, [bool]$BackupFiles ) $Success = $false try { # Create daily snapshot $SnapshotSuccess = Create-DailySnapshot -VMName $VMName -MaxSnapshots $MaxSnapshots -SnapshotPrefix $SnapshotPrefix if ($SnapshotSuccess -and $BackupFiles) { # Backup snapshot files to NAS/Local storage $SnapshotFiles = Get-VMSnapshotFiles -VMName $VMName if ($SnapshotFiles.Count -gt 0) { # Backup to local storage if (Test-Path $LocalSnapshotBackupPath) { $LocalBackupSuccess = Backup-SnapshotFiles -VMName $VMName -SnapshotFiles $SnapshotFiles -BackupPath $LocalSnapshotBackupPath } # Backup to NAS if accessible if (Test-NetworkPath $NASBackupPath) { $NASBackupSuccess = Backup-SnapshotFiles -VMName $VMName -SnapshotFiles $SnapshotFiles -BackupPath $NASBackupPath # Cleanup old NAS backups Cleanup-OldSnapshotBackups -BackupPath $NASBackupPath -VMName $VMName -MaxBackups $MaxSnapshots } # Cleanup old local backups if (Test-Path $LocalSnapshotBackupPath) { Cleanup-OldSnapshotBackups -BackupPath $LocalSnapshotBackupPath -VMName $VMName -MaxBackups $MaxSnapshots } } } $Success = $SnapshotSuccess } catch { Write-Log "Error processing VM $VMName : $_" "ERROR" } return $Success } # Main execution try { Write-Log "Starting daily snapshot backup process" # Create directories if they don't exist if ($BackupSnapshotFiles) { $null = New-Item -ItemType Directory -Path $LocalSnapshotBackupPath -Force -ErrorAction SilentlyContinue if (-not (Test-NetworkPath $NASBackupPath)) { Write-Log "NAS path not accessible - will only backup locally" "WARNING" } } # Determine which VMs to process $VMsToProcess = @() if ($AllVMs) { Write-Log "Processing snapshots for all VMs (excluding specified VMs)" $AllVirtualMachines = Get-VM | Where-Object { $_.Name -notin $ExcludeVMs } $VMsToProcess = $AllVirtualMachines.Name if ($ExcludeVMs.Count -gt 0) { Write-Log "Excluded VMs: $($ExcludeVMs -join ', ')" } Write-Log "VMs to process: $($VMsToProcess -join ', ')" } else { $VMsToProcess = @($VMName) Write-Log "Processing single VM: $VMName" } if ($VMsToProcess.Count -eq 0) { throw "No VMs found to process" } # Process each VM $SuccessCount = 0 $FailureCount = 0 $Results = @() foreach ($VM in $VMsToProcess) { $StartTime = Get-Date $Success = Process-SingleVM -VMName $VM -MaxSnapshots $MaxSnapshots -SnapshotPrefix $SnapshotPrefix -BackupFiles $BackupSnapshotFiles.IsPresent $EndTime = Get-Date $Duration = ($EndTime - $StartTime).TotalMinutes $Results += [PSCustomObject]@{ VMName = $VM Success = $Success Duration = [math]::Round($Duration, 1) } if ($Success) { $SuccessCount++ } else { $FailureCount++ } } # Final summary Write-Log "=== DAILY SNAPSHOT SUMMARY ===" Write-Log "Total VMs processed: $($VMsToProcess.Count)" Write-Log "Successful snapshots: $SuccessCount" Write-Log "Failed snapshots: $FailureCount" Write-Log "Snapshot file backup: $(if ($BackupSnapshotFiles) { 'ENABLED' } else { 'DISABLED' })" foreach ($Result in $Results) { $Status = if ($Result.Success) { "SUCCESS" } else { "FAILED" } Write-Log "$($Result.VMName): $Status ($($Result.Duration) minutes)" } # Show current snapshot status for all VMs Write-Log "=== CURRENT SNAPSHOT STATUS ===" foreach ($VM in $VMsToProcess) { try { $Snapshots = Get-VMCheckpoint -VMName $VM | Where-Object { $_.Name -like "$SnapshotPrefix*" } | Sort-Object CreationTime -Descending Write-Log "$VM : $($Snapshots.Count) $SnapshotPrefix snapshots" # Show total size of snapshot files $SnapshotFiles = Get-VMSnapshotFiles -VMName $VM $TotalSize = 0 foreach ($File in $SnapshotFiles) { if (Test-Path $File.FilePath) { $TotalSize += (Get-Item $File.FilePath).Length } } $TotalSizeGB = [math]::Round($TotalSize / 1GB, 2) Write-Log "$VM : Total snapshot storage: $TotalSizeGB GB" } catch { Write-Log "$VM : Could not get snapshot status" "WARNING" } } if ($FailureCount -gt 0) { Write-Log "Daily snapshot process completed with $FailureCount failure(s)" "WARNING" exit 1 } else { Write-Log "Daily snapshot process completed successfully" "SUCCESS" } } catch { Write-Log "Daily snapshot process failed: $_" "ERROR" exit 1 } # Example usage: # Single VM with snapshots only: # .\Daily-SnapshotBackup.ps1 -VMName "MyVM" -MaxSnapshots 7 # # Single VM with snapshot file backup: # .\Daily-SnapshotBackup.ps1 -VMName "MyVM" -MaxSnapshots 7 -BackupSnapshotFiles # # All VMs with snapshots only: # .\Daily-SnapshotBackup.ps1 -AllVMs -MaxSnapshots 7 # # All VMs with snapshot file backup: # .\Daily-SnapshotBackup.ps1 -AllVMs -MaxSnapshots 7 -BackupSnapshotFiles # # All VMs except specific ones: # .\Daily-SnapshotBackup.ps1 -AllVMs -ExcludeVMs @("TestVM", "TempVM") -MaxSnapshots 7 -BackupSnapshotFiles # Daily snapshot workflow: # 1. Create daily checkpoint/snapshot for each VM # 2. Optionally backup the differencing files (.avhdx) and config files (.vmrs) to local/NAS storage # 3. Automatically cleanup old snapshots (keeps specified number) # 4. Cleanup old snapshot file backups # 5. Report on current snapshot storage usage # # Benefits: # - Near-instant snapshot creation (seconds, not minutes/hours) # - Minimal storage overhead (only stores changes since last snapshot) # - No VM downtime at all # - Can restore to any point in the last N days quickly # - Differencing files are much smaller than full backups