A PowerShell 5.1 script that automatically backs up one or more Hyper-V VMs to an Azure Blob Storage container using a SAS key. VHD/VHDX paths are auto-discovered per VM — no hardcoding required. A configurable number of dated versions are retained in Azure, and email alerts are sent on failure.
Generate in Azure Portal → Storage Account → Shared Access Signature with the following settings:
| Setting | Value |
|---|---|
| Allowed services | Blob |
| Allowed resource types | Container, Object |
| Allowed permissions | Read, Write, Delete, List |
| Expiry | At least 1–2 years ahead |
| Allowed protocols | HTTPS only |
Copy the token string starting from sv=... (without the leading ?) into $SASToken in the script.
Each disk is uploaded with the VM name, disk name and date as the blob name:
{VMName}_{DiskBaseName}_{yyyy-MM-dd}.{ext}
Example:
W2K8SQL01_W2K8SQL01_C2_2026-04-16.VHD
W2K8SQL01_W2K8SQL01_E_2026-04-16.VHD
WebServer01_WebServer01_C_2026-04-16.VHDX
Old versions beyond $KeepVersions are automatically deleted after each successful upload.
Stop-VM -Force, waits up to 10 min)Each VM runs independently — if one fails, the remaining VMs are still processed.
| Field | Value |
|---|---|
| Run As | SYSTEM or a local Administrator account |
| Run whether logged on or not | ✓ |
| Trigger | Weekly, e.g. Sunday at 02:00 |
| Program | powershell.exe |
| Arguments | -NonInteractive -ExecutionPolicy Bypass -File "C:\Scripts\Backup-HyperV.ps1" |
#Requires -RunAsAdministrator
# ==============================================================
# Backup-HyperV.ps1
# Weekly Hyper-V VM backup to Azure Blob Storage via SAS key
#
# Features:
# - Multi-VM support via simple name array
# - Auto-discovers all VHDs/VHDXs attached to each VM
# - Uploads directly to Azure while VM is offline (no temp copy)
# - Retains a configurable number of versions per disk in Azure
# - Restarts VMs after upload regardless of success/failure
# - Logs to disk and sends email alerts on failure
#
# Requirements:
# - Windows PowerShell 5.1+
# - Hyper-V PowerShell module (RSAT or host role)
# - Internet access for initial AzCopy download
# - Run as Administrator
# ==============================================================
# ─── CONFIGURATION ────────────────────────────────────────────
# VMs to back up (must match exact Hyper-V VM names)
$VMNames = @(
"W2K8SQL01",
"WebServer01"
)
# Azure Blob Storage
# Generate SAS token in Azure Portal → Storage Account → Shared Access Signature
# Required permissions: Read, Write, Delete, List | Scope: Container + Object
$StorageAccount = "fenzirtaxstoracc"
$Container = "backup"
$SASToken = "sv=PASTE_YOUR_SAS_TOKEN_HERE"
# How many dated versions to keep per disk in Azure (minimum recommended: 2)
$KeepVersions = 2
# AzCopy path – downloaded automatically on first run if not found
$AzCopyExe = "C:\Tools\azcopy\azcopy.exe"
# Log directory (one log file created per run)
$LogDir = "C:\Logs\HyperVBackup"
# Email alerts on failure (leave SMTPUser/SMTPPass empty for unauthenticated relay)
$EmailFrom = "hyperv-backup@yourdomain.com"
$EmailTo = "admin@yourdomain.com"
$SMTPServer = "smtp.yourdomain.com"
$SMTPPort = 587
$SMTPUser = ""
$SMTPPass = ""
# ─────────────────────────────────────────────────────────────
$DateStamp = Get-Date -Format "yyyy-MM-dd"
$LogFile = "$LogDir\backup_$DateStamp.log"
$BaseURL = "https://$StorageAccount.blob.core.windows.net/$Container"
# ── Helper functions ──────────────────────────────────────────
function Write-Log {
param([string]$Message, [string]$Level = "INFO")
$line = "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] [$Level] $Message"
Write-Host $line
Add-Content -Path $LogFile -Value $line -Encoding UTF8
}
function Send-AlertEmail {
param([string]$Subject, [string]$Body)
try {
$params = @{
From = $EmailFrom
To = $EmailTo
Subject = $Subject
Body = $Body
SmtpServer = $SMTPServer
Port = $SMTPPort
}
if ($SMTPUser -ne "") {
$cred = New-Object System.Management.Automation.PSCredential(
$SMTPUser,
(ConvertTo-SecureString $SMTPPass -AsPlainText -Force)
)
$params.Credential = $cred
$params.UseSsl = $true
}
Send-MailMessage @params
Write-Log "Alert email sent to $EmailTo"
} catch {
Write-Log "Failed to send alert email: $_" "WARN"
}
}
function Start-VMAndWait {
param([string]$Name, [int]$TimeoutSeconds = 300)
try {
$state = (Get-VM -Name $Name -ErrorAction Stop).State
if ($state -eq "Running") { return }
Write-Log "[$Name] Starting VM..."
Start-VM -Name $Name -ErrorAction Stop
$elapsed = 0
while ((Get-VM -Name $Name).State -ne "Running" -and $elapsed -lt $TimeoutSeconds) {
Start-Sleep -Seconds 5; $elapsed += 5
}
if ((Get-VM -Name $Name).State -eq "Running") {
Write-Log "[$Name] VM is running."
} else {
Write-Log "[$Name] VM did not reach Running state within $TimeoutSeconds seconds!" "WARN"
}
} catch {
Write-Log "[$Name] Could not start VM: $_" "ERROR"
}
}
function Stop-VMAndWait {
param([string]$Name, [int]$TimeoutSeconds = 600)
Write-Log "[$Name] Shutting down VM..."
Stop-VM -Name $Name -Force -ErrorAction Stop
$elapsed = 0
while ((Get-VM -Name $Name).State -ne "Off" -and $elapsed -lt $timeout) {
Start-Sleep -Seconds 10; $elapsed += 10
Write-Log "[$Name] Waiting for shutdown... ($elapsed s, state: $((Get-VM -Name $Name).State))"
}
if ((Get-VM -Name $Name).State -ne "Off") {
throw "[$Name] VM did not stop within $TimeoutSeconds seconds"
}
Write-Log "[$Name] VM is Off."
}
function Get-VMDiskPaths {
param([string]$Name)
$disks = Get-VMHardDiskDrive -VMName $Name -ErrorAction Stop
if (-not $disks) { throw "[$Name] No hard disks found attached to VM" }
$paths = $disks | Select-Object -ExpandProperty Path
Write-Log "[$Name] Discovered $($paths.Count) disk(s):"
foreach ($p in $paths) { Write-Log "[$Name] $p" }
return $paths
}
function Get-BlobsWithPrefix {
param([string]$Prefix)
try {
$uri = "$BaseURL`?restype=container&comp=list&prefix=$Prefix&$SASToken"
$response = Invoke-WebRequest -Uri $uri -Method GET -UseBasicParsing -ErrorAction Stop
[xml]$xml = $response.Content
$blobs = $xml.EnumerationResults.Blobs.Blob
if ($null -eq $blobs) { return @() }
return @($blobs | Sort-Object { [datetime]$_.Properties."Last-Modified" })
} catch {
Write-Log "Failed to list blobs with prefix '$Prefix': $_" "WARN"
return @()
}
}
function Remove-OldBlobVersions {
param([string]$BlobPrefix)
$blobs = Get-BlobsWithPrefix -Prefix $BlobPrefix
if ($blobs.Count -gt $KeepVersions) {
$toDelete = $blobs | Select-Object -First ($blobs.Count - $KeepVersions)
foreach ($blob in $toDelete) {
$deleteUri = "$BaseURL/$($blob.Name)?$SASToken"
try {
Invoke-WebRequest -Uri $deleteUri -Method DELETE -UseBasicParsing -ErrorAction Stop | Out-Null
Write-Log " Deleted old version: $($blob.Name)"
} catch {
Write-Log " Could not delete blob '$($blob.Name)': $_" "WARN"
}
}
} else {
Write-Log " Versions OK: '$BlobPrefix' ($($blobs.Count)/$KeepVersions retained)"
}
}
function Install-AzCopy {
Write-Log "AzCopy not found – downloading to $AzCopyExe..."
New-Item -ItemType Directory -Force -Path (Split-Path $AzCopyExe) | Out-Null
$zipPath = "$env:TEMP\azcopy_download.zip"
$extractPath = "$env:TEMP\azcopy_extract"
Invoke-WebRequest -Uri "https://aka.ms/downloadazcopy-v10-windows" `
-OutFile $zipPath -UseBasicParsing -ErrorAction Stop
if (Test-Path $extractPath) { Remove-Item $extractPath -Recurse -Force }
Expand-Archive -Path $zipPath -DestinationPath $extractPath -Force
$exe = Get-ChildItem $extractPath -Recurse -Filter "azcopy.exe" | Select-Object -First 1
if ($null -eq $exe) { throw "azcopy.exe not found in downloaded zip" }
Copy-Item $exe.FullName $AzCopyExe -Force
Write-Log "AzCopy ready: $AzCopyExe"
}
function Invoke-VMBackup {
param([string]$VMName)
Write-Log "[$VMName] ── Starting backup ──────────────────────"
$vmStopped = $false
$uploadErrors = @()
try {
# Validate VM exists
$vm = Get-VM -Name $VMName -ErrorAction Stop
Write-Log "[$VMName] Found VM (current state: $($vm.State))"
# Discover attached disks
$diskPaths = Get-VMDiskPaths -Name $VMName
# Validate all disk files exist before touching the VM
foreach ($disk in $diskPaths) {
if (-not (Test-Path $disk)) {
throw "Disk file not found on host: $disk"
}
}
# ── Stop VM ───────────────────────────────────────────
Stop-VMAndWait -Name $VMName
$vmStopped = $true
# ── Upload each disk directly to Azure ────────────────
foreach ($disk in $diskPaths) {
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($disk)
$ext = [System.IO.Path]::GetExtension($disk)
$blobName = "${VMName}_${baseName}_$DateStamp$ext"
$uploadURL = "$BaseURL/$blobName`?$SASToken"
$sizeMB = [math]::Round((Get-Item $disk).Length / 1MB, 1)
Write-Log "[$VMName] Uploading: $disk ($sizeMB MB) → $blobName"
$sw = [System.Diagnostics.Stopwatch]::StartNew()
$output = & $AzCopyExe copy $disk $uploadURL --overwrite=true 2>&1
$sw.Stop()
if ($LASTEXITCODE -ne 0) {
$msg = "Upload FAILED for '$blobName': $output"
Write-Log "[$VMName] $msg" "ERROR"
$uploadErrors += $msg
} else {
Write-Log "[$VMName] Upload OK: '$blobName' ($([math]::Round($sw.Elapsed.TotalMinutes, 1)) min.)"
}
}
} finally {
# Always restart VM if it was stopped
if ($vmStopped) {
Start-VMAndWait -Name $VMName
}
}
# Prune old versions (only for successfully uploaded disks)
if ($uploadErrors.Count -eq 0) {
Write-Log "[$VMName] Pruning old blob versions..."
foreach ($disk in $diskPaths) {
$baseName = [System.IO.Path]::GetFileNameWithoutExtension($disk)
Remove-OldBlobVersions -BlobPrefix "${VMName}_${baseName}"
}
}
if ($uploadErrors.Count -gt 0) {
throw "[$VMName] $($uploadErrors.Count) upload(s) failed:`n" + ($uploadErrors -join "`n")
}
Write-Log "[$VMName] ── Backup complete ─────────────────────"
}
# ── MAIN ─────────────────────────────────────────────────────
New-Item -ItemType Directory -Force -Path $LogDir | Out-Null
Write-Log "══════════════════════════════════════════════"
Write-Log " Hyper-V Backup Job started"
Write-Log " Date : $DateStamp"
Write-Log " VMs : $($VMNames -join ', ')"
Write-Log "══════════════════════════════════════════════"
# Check/install AzCopy once before processing any VMs
try {
if (-not (Test-Path $AzCopyExe)) { Install-AzCopy }
} catch {
$msg = "Failed to install AzCopy: $_"
Write-Log $msg "ERROR"
Send-AlertEmail -Subject "[BACKUP ERROR] AzCopy installation failed – $DateStamp" -Body $msg
exit 1
}
$failedVMs = @()
foreach ($vm in $VMNames) {
try {
Invoke-VMBackup -VMName $vm
} catch {
$errorMsg = $_.Exception.Message
Write-Log $errorMsg "ERROR"
$failedVMs += [PSCustomObject]@{ VM = $vm; Error = $errorMsg }
}
}
Write-Log "══════════════════════════════════════════════"
if ($failedVMs.Count -gt 0) {
Write-Log "Job finished WITH ERRORS ($($failedVMs.Count)/$($VMNames.Count) VMs failed)" "ERROR"
$bodyLines = @("Hyper-V backup completed with errors on $DateStamp.", "")
foreach ($f in $failedVMs) {
$bodyLines += "VM: $($f.VM)"
$bodyLines += "Error: $($f.Error)"
$bodyLines += ""
}
$bodyLines += "Log file: $LogFile"
$bodyLines += "Storage: https://$StorageAccount.blob.core.windows.net/$Container"
Send-AlertEmail `
-Subject "[BACKUP ERROR] $($failedVMs.Count) VM(s) failed – $DateStamp" `
-Body ($bodyLines -join "`n")
exit 1
} else {
Write-Log "Job finished successfully ($($VMNames.Count)/$($VMNames.Count) VMs backed up)"
exit 0
}
[2026-04-16 02:00:01] [INFO] ══════════════════════════════════════════════
[2026-04-16 02:00:01] [INFO] Hyper-V Backup Job started
[2026-04-16 02:00:01] [INFO] Date : 2026-04-16
[2026-04-16 02:00:01] [INFO] VMs : W2K8SQL01, WebServer01
[2026-04-16 02:00:01] [INFO] ══════════════════════════════════════════════
[2026-04-16 02:00:02] [INFO] [W2K8SQL01] ── Starting backup ──────────────────────
[2026-04-16 02:00:02] [INFO] [W2K8SQL01] Found VM (current state: Running)
[2026-04-16 02:00:02] [INFO] [W2K8SQL01] Discovered 2 disk(s):
[2026-04-16 02:00:02] [INFO] [W2K8SQL01] C:\VHDs\W2K8SQL01_C2.VHD
[2026-04-16 02:00:02] [INFO] [W2K8SQL01] E:\VHDs\W2K8SQL01_E.VHD
[2026-04-16 02:00:02] [INFO] [W2K8SQL01] Shutting down VM...
[2026-04-16 02:00:45] [INFO] [W2K8SQL01] VM is Off.
[2026-04-16 02:00:45] [INFO] [W2K8SQL01] Uploading: C:\VHDs\W2K8SQL01_C2.VHD (40960 MB) → W2K8SQL01_W2K8SQL01_C2_2026-04-16.VHD
[2026-04-16 02:47:12] [INFO] [W2K8SQL01] Upload OK: 'W2K8SQL01_W2K8SQL01_C2_2026-04-16.VHD' (46.4 min.)
[2026-04-16 02:47:12] [INFO] [W2K8SQL01] Starting VM...
[2026-04-16 02:47:20] [INFO] [W2K8SQL01] VM is running.
[2026-04-16 02:47:20] [INFO] [W2K8SQL01] Pruning old blob versions...
[2026-04-16 02:47:21] [INFO] Deleted old version: W2K8SQL01_W2K8SQL01_C2_2026-04-02.VHD
[2026-04-16 02:47:21] [INFO] [W2K8SQL01] ── Backup complete ─────────────────────
Script maintained by your infrastructure team. Save as Backup-HyperV.ps1 and schedule via Windows Task Scheduler.