Hyper-V Weekly Backup to Azure Blob Storage

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.


Requirements

  • Windows PowerShell 5.1+
  • Hyper-V PowerShell module (available on Hyper-V hosts by default)
  • An Azure Storage Account with a container and a valid SAS token
  • Internet access on the host (for initial AzCopy download)
  • Run as Administrator / SYSTEM via Task Scheduler

Azure SAS Token

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.


Blob Naming Convention

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.


Backup Flow

  1. Validate all VMs and disk files exist
  2. Shut down VM (Stop-VM -Force, waits up to 10 min)
  3. Upload each disk directly to Azure via AzCopy (no local temp copy)
  4. Restart VM — always, even if upload failed
  5. Prune old blob versions beyond the configured limit
  6. Send alert email if any errors occurred

Each VM runs independently — if one fails, the remaining VMs are still processed.


Task Scheduler Setup

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"

Script

#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
}

Log Output Example

[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.

Previous Post