Skip to content
The TODO List: Technical Highlights Back to Blog
Oct 13

Steven

A Unique Approach to Disaster Recovery

  • Oct 13, 2026
  • Steven

Every backup product promises the same thing: when something breaks, you get your machines back. I wanted something simpler. No agents, no catalog, no backup server to rebuild before I can restore anything. Just last night’s copy of the VM, sitting in a folder, ready to import. It won’t replace an enterprise backup suite everywhere. But if you run Hyper-V and have a stack of spare drives, it might be all the backup you need.

A Unique Approach to Disaster Recovery

Nightly full-image backups of Hyper-V virtual machines using built-in export, two PowerShell scripts, and Windows Data Deduplication. No backup software, and no proprietary format between you and your data.

Steven Hill · Dial Exascale Computing

At a glance. Platform: Hyper-V on Windows Server. Backup engine: Export-VM, driven by PowerShell 7. Storage: independent drives with Data Deduplication. Retention: 30 to 365 days of nightly slices per volume, plus one slice a month kept indefinitely. Best result: about 77 TB of data in 4.6 TB of disk (94% savings). Software cost: none beyond Windows Server.

Background

My rack at Corespace includes a dedicated (and admittedly clapped-out) machine that does storage and little else. It has 24 drive bays on its backplane. It has served as a SAN in the past; today it handles backup and network storage. As long as it keeps running, it stays. The 192 GB of memory in it alone is worth more than gold.

I run a number of projects and production workloads on this infrastructure: an application server, a GPU and development server, and even a backup DNS and Active Directory server that lives on the storage server itself. All of them need backup. It only takes one bad patch from Microsoft to ruin your day, and a bad update on a Unix or Linux VM can do the same. (oh nooo Linux never messes up. My bad) When that happens, the fastest fix is to put yesterday’s machine back.

What a backup solution must do

Whenever I evaluate a backup solution, on premises or in a data center, I start with what it actually needs to do:

  1. Run on a schedule. Nightly, or whatever period each workload calls for.
  2. Keep the media portable. I should be able to pull a drive, connect it to another machine, and extract data from it.
  3. Find and restore a specific VM easily. No hunting through catalogs or proprietary archives.
  4. Bonus: run a VM in place. If something critical has to come back immediately, I can attach the VM directly from the backup volume and run it there.
  5. Move off-site easily. Backups should be simple to compress and carry out for off-site storage.

Most enterprise backup products, especially the VM-oriented ones, cover most of this list. They also come with licensing costs and their own backup formats. I wanted to see how far I could get with what Windows Server and Hyper-V already provide.

The building blocks

Hyper-V export and import

I run Hyper-V. It’s the road less traveled, I know. One thing Hyper-V does very well is export a virtual machine’s complete state to disk: its configuration, virtual hard disks, and checkpoints, all in one folder. You can do this from Hyper-V Manager:

The Export Virtual Machine dialog in Hyper-V Manager

Right-click a VM in Hyper-V Manager, choose Export, and pick a destination folder.

Or from PowerShell, which is what makes automation possible:

Export-VM -Name 'MyVM' -Path 'E:\Exports'

Since Windows Server 2012 R2, the VM doesn’t need to be shut down first. A running VM can be exported live, and the copy is captured in a saved state. Import that export on another Hyper-V host with a compatible processor and it literally picks up where it left off.

$vmConfig = Get-ChildItem 'E:\Exports\MyVM\Virtual Machines\*.vmcx'
Import-VM -Path $vmConfig.FullName -Copy -GenerateNewId

[Screenshot (optional): the Import Virtual Machine wizard in Hyper-V Manager.]

Independent drives, not one big array

I deliberately don’t stripe the backup drives into one large volume. In a catastrophic failure, I need to be able to take a single physical drive, connect it to whatever is available (a laptop running a Windows Server VM, for instance), and read it. So the backups live on a set of spare drives of varying sizes, each formatted as its own independent volume. Losing a drive costs me the backups on that drive, not all of them.

The Windows Server VM is the important detail. Deduplicated files can only be read by a system that has the Data Deduplication feature installed, and Windows client editions don’t include it.

Deduplication: six months on a single drive

What makes nightly full exports affordable is Windows Server Data Deduplication. Every night’s export is a complete copy of each VM, but from one night to the next almost all of those blocks are identical. Deduplication stores each unique block once. That’s how I can keep roughly six months of daily exports on a single drive.

77 TB in 4.6 TB. About 77 TB of data occupies 4.6 TB of disk on volume 6, a single 8 TB drive: 72.2 TB of deduplication savings, a 94% rate, roughly 17:1. Across all ten backup drives, about 470 TB of data fits in 36 TB of disk.

Deduplication rate and savings for each backup volume on the storage server

Deduplication across the storage server’s ten backup drives. Volume 9 is the newest and is still filling.

Placement matters too. Storing similar machines on the same drive tightens deduplication further. Most of my development VMs share a drive, so all of those copies of Windows and the same development tools collapse together. In effect, there is one copy of Windows on that drive, plus the unique changes for each VM and each night.

The key idea: every nightly slice is a complete, independently importable VM, yet each extra night only uses space for what actually changed. The restore simplicity of full backups at roughly the storage cost of incrementals.

How it works

A small set of PowerShell scripts runs every day. One decides what gets backed up tonight, two do the exporting, and one prunes expired backups and starts deduplication. The whole pipeline looks like this:

The nightly cycle in five steps. 1 Tag: each VM’s name carries its volume and schedule. 2 Plan: at 5:00 PM, one list per volume of the VMs due tonight, every VM on the 1st and 15th. 3 Launch: one export process per volume, all ten at once. 4 Export: each VM into tonight’s dated slice on its own drive. 5 Prune and optimize: expired slices deleted, deduplication collapses the repeats.

The VM name is the configuration

Each virtual machine’s name includes its backup policy in parentheses: which volume it goes to and how often it runs.

Hyper-V Manager VM list with a backup tag at the end of each name

Guacamole(V1WED) goes to volume 1 every Wednesday; NewTethys(V0AL7) goes to volume 0 every night. VMs with no tag, like SB Web Server, aren’t backed up by these scripts.

Tag

Meaning

V2

Store on backup volume 2

AL7

Back up every day (all seven days)

M-F

Back up Monday through Friday

MWF

Back up Monday, Wednesday and Friday

T-T

Back up Tuesday and Thursday

MON, TUE, WED, THU, FRI, SAT, SUN

Back up once a week, on that day

V6SAT

Store on volume 6, back up on Saturdays

This keeps the policy attached to the machine itself. It’s visible in Hyper-V Manager, there’s no separate database or configuration file to maintain, and changing a VM’s schedule is just a rename.

Step 1: build tonight’s list (5:00 PM)

The first script runs at 5:00 PM. It reads every VM’s name, works out which machines are due tonight and which volume each belongs to, and writes one list per volume (volume0.txt through volume9.txt) with one VM name per line. On the 1st and 15th of each month it ignores the schedules and lists every tagged VM, so there is a complete set of backups twice a month.

Here is the script:

<#
.SYNOPSIS
    Builds tonight's backup lists (volume0.txt ... volume9.txt) from the tag in each VM's name.

.DESCRIPTION
    Runs at 5:00 PM on each Hyper-V host. A VM is backed up when its name contains a tag in
    parentheses: V + volume number + schedule code, for example (V8SUN) or (V2M-F).

    Schedule codes:  AL7 every day   M-F weekdays   MWF Mon/Wed/Fri   T-T Tue/Thu
                     MON TUE WED THU FRI SAT SUN   that day only

    On a full-backup day (the 1st and 15th of the month by default) every tagged VM is included,
    whatever its schedule. Each VM is checked once, so the lists never contain duplicates.

    VMs with no tag, or with a schedule code this script doesn't know, are logged so nothing
    silently goes without a backup. Everything is logged to buildvolumelists.log.

    Task Scheduler runs it with no arguments.

.EXAMPLE
    .\buildvolumelists.ps1
.EXAMPLE
    .\buildvolumelists.ps1 -FullBackupDays 1
    Back up everything on the 1st only.
.EXAMPLE
    .\buildvolumelists.ps1 -RunDate '2026-11-01'
    Build the lists as if it were Nov 1 (for testing).
#>
param(
    # Where the volume lists and the log live
    [string]$BatchFolder = 'C:\batch',

    # Days of the month on which every tagged VM is backed up
    [ValidateRange(1, 31)]
    [int[]]$FullBackupDays = @(1, 15),

    # The date to build lists for; defaults to today
    [datetime]$RunDate = (Get-Date)
)

# Which schedule codes run on each day of the week
$schedulesByDay = @{
    Monday    = @('AL7', 'M-F', 'MWF', 'MON')
    Tuesday   = @('AL7', 'M-F', 'T-T', 'TUE')
    Wednesday = @('AL7', 'M-F', 'MWF', 'WED')
    Thursday  = @('AL7', 'M-F', 'T-T', 'THU')
    Friday    = @('AL7', 'M-F', 'MWF', 'FRI')
    Saturday  = @('AL7', 'SAT')
    Sunday    = @('AL7', 'SUN')
}
$knownScheduleCodes = @($schedulesByDay.Values | ForEach-Object { $_ } | Sort-Object -Unique)

# The tag must be inside parentheses, so "DEV1" or "Server V2" in a VM's name can't match by accident
$backupTagPattern = '\(V(?<volume>[0-9])(?<schedule>[A-Z0-9-]+)\)'

$logPath = Join-Path $BatchFolder 'buildvolumelists.log'

function Write-Log {
    param([Parameter(Mandatory)][string]$Message)

    $logLine = '{0}  {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
    Write-Host $logLine
    Add-Content -LiteralPath $logPath -Value $logLine
}

# Widen the dynamic (ephemeral) TCP port range (needs admin; the setting persists across reboots)
try {
    & netsh int ipv4 set dynamicport tcp start=1025 num=64510 | Out-Null
}
catch {
    Write-Log "WARNING  could not set the dynamic port range: $($_.Exception.Message)"
}

$todaysScheduleCodes = $schedulesByDay[$RunDate.DayOfWeek.ToString()]
$isFullBackupDay     = $FullBackupDays -contains $RunDate.Day

if ($isFullBackupDay) {
    Write-Log "==== Building lists for $($RunDate.ToString('ddd yyyy-MM-dd')): full backup day, every tagged VM"
}
else {
    Write-Log "==== Building lists for $($RunDate.ToString('ddd yyyy-MM-dd')): schedules $($todaysScheduleCodes -join ', ')"
}

$vmNamesByVolume = @{}
foreach ($volumeNumber in 0..9) {
    $vmNamesByVolume[$volumeNumber] = [System.Collections.Generic.List[string]]::new()
}

try {
    Import-Module Hyper-V -ErrorAction Stop
    $virtualMachines = @(Get-VM -ErrorAction Stop | Sort-Object Name)
}
catch {
    Write-Log "FAILED   could not read the VM list: $($_.Exception.Message)"
    exit 1
}

foreach ($virtualMachine in $virtualMachines) {
    $vmName = $virtualMachine.Name

    if ($vmName -notmatch $backupTagPattern) {
        Write-Log "WARNING  no backup tag, not backed up: $vmName"
        continue
    }

    $volumeNumber = [int]$Matches['volume']
    $scheduleCode = $Matches['schedule'].ToUpperInvariant()
    $isKnownCode  = $knownScheduleCodes -contains $scheduleCode

    if (-not $isKnownCode) {
        Write-Log "WARNING  unknown schedule code '$scheduleCode', only backed up on full backup days: $vmName"
    }

    if ($isFullBackupDay -or ($isKnownCode -and $todaysScheduleCodes -contains $scheduleCode)) {
        $vmNamesByVolume[$volumeNumber].Add($vmName)
    }
}

# Write every list fresh, including empty ones, so yesterday's names never carry over
foreach ($volumeNumber in 0..9) {
    $listPath = Join-Path $BatchFolder "volume$volumeNumber.txt"
    $volumeVmNames = $vmNamesByVolume[$volumeNumber]
    try {
        Set-Content -LiteralPath $listPath -Value $volumeVmNames -Encoding UTF8 -ErrorAction Stop
        if ($volumeVmNames.Count -gt 0) {
            Write-Log ("vol{0}  {1} VMs: {2}" -f $volumeNumber, $volumeVmNames.Count, ($volumeVmNames -join '; '))
        }
    }
    catch {
        Write-Log "FAILED   could not write ${listPath}: $($_.Exception.Message)"
        exit 1
    }
}

$totalVmCount = ($vmNamesByVolume.Values | Measure-Object -Property Count -Sum).Sum
Write-Log "==== Done: $totalVmCount VMs scheduled tonight"

Each VM’s tag is read once, so the lists never hold duplicates. The tag only counts inside the parentheses, so a VM called DEV1 can’t land on volume 1 by accident. VMs with no tag, or with a schedule code the script doesn’t recognize, are logged as warnings, so nothing silently goes without a backup.

A generated list for volume 2, one VM name per line

Tonight’s list for volume 2: three VMs tagged V2AL7, one name per line.

Because each list is plain text, it’s easy to adjust. If I add a new VM or need an extra backup of one machine, I can edit the file by hand or simply rerun the script.

Step 2: launch one export per volume

The second stage starts with a small launcher. It opens a separate PowerShell 7 process for each volume, 0 through 9, all at the same time, logs when each one started, and exits. Run it with -Wait and it stays until every export finishes, then logs whether each one succeeded.

<#
.SYNOPSIS
    Starts one export process per archive volume (0-9), all at once.

.DESCRIPTION
    Task Scheduler runs it with no arguments. It starts every volume's export and exits without
    waiting; each export logs its own results to exportvol<N>.log. The volume number is passed
    by position (exportvolume.ps1 3).

    Optional: add -Wait to stay running until every export finishes and log whether each one
    succeeded. With -Wait the script exits with code 1 if any volume had a problem.

.EXAMPLE
    .\startbackups.ps1
    Starts every export and exits.

.EXAMPLE
    .\startbackups.ps1 -Wait
    Also waits for every export and records how each one ended.
#>
param(
    # Wait for every export and log how each one ended
    [switch]$Wait,

    # Stop waiting after this long so a hung export can't overlap tomorrow's run (only with -Wait)
    [ValidateRange(1, 23)]
    [int]$MaxWaitHours = 20,

    # Where exportvolume.ps1, the VM lists and the logs live
    [string]$BatchFolder = 'C:\batch',

    # PowerShell 7, used to run each volume's export
    [string]$PowerShellPath = 'C:\Program Files\PowerShell\7\pwsh.exe'
)

$exportScriptPath = Join-Path $BatchFolder 'exportvolume.ps1'
$logPath          = Join-Path $BatchFolder 'startbackuptasks.log'
$volumeNumbers    = 0..9

function Write-Log {
    param([Parameter(Mandatory)][string]$Message)

    $logLine = '{0}  {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
    Write-Host $logLine
    Add-Content -LiteralPath $logPath -Value $logLine
}

Set-Location -LiteralPath $BatchFolder
$runTimer     = [System.Diagnostics.Stopwatch]::StartNew()
$problemCount = 0
Write-Log '==== Backup run started'

# Start every volume at once, passing the volume number by position
$runningExports = foreach ($volumeNumber in $volumeNumbers) {
    try {
        $exportProcess = Start-Process -FilePath $PowerShellPath `
            -ArgumentList '-File', "`"$exportScriptPath`"", $volumeNumber `
            -PassThru -ErrorAction Stop
        $null = $exportProcess.Handle   # keeps the exit code readable after the process ends
        Write-Log "vol$volumeNumber  started (process $($exportProcess.Id))"
        [pscustomobject]@{ VolumeNumber = $volumeNumber; Process = $exportProcess }
    }
    catch {
        Write-Log "vol$volumeNumber  FAILED to start: $($_.Exception.Message)"
        $problemCount++
    }
}

if ($Wait) {
    # Wait for each one and record how it ended
    $waitDeadline = (Get-Date).AddHours($MaxWaitHours)
    foreach ($runningExport in $runningExports) {
        $volumeLabel = "vol$($runningExport.VolumeNumber)"
        $remainingMilliseconds = [int][math]::Max(0, ($waitDeadline - (Get-Date)).TotalMilliseconds)

        if ($runningExport.Process.WaitForExit($remainingMilliseconds)) {
            $exitCode = $runningExport.Process.ExitCode
            if ($exitCode -eq 0) {
                Write-Log "$volumeLabel  finished OK"
            }
            else {
                Write-Log "$volumeLabel  FINISHED WITH ERRORS (exit code $exitCode), see exportvol$($runningExport.VolumeNumber).log"
                $problemCount++
            }
        }
        else {
            Write-Log "$volumeLabel  STILL RUNNING after $MaxWaitHours hours (process $($runningExport.Process.Id))"
            $problemCount++
        }
    }
    Write-Log ('==== Backup run finished in {0:hh\:mm\:ss}: {1} problems' -f $runTimer.Elapsed, $problemCount)
}
else {
    Write-Log "==== All exports started: $(@($runningExports).Count) running, $problemCount failed to start"
}

if ($problemCount -gt 0) {
    exit 1
}

The concurrency is deliberate. Each process writes to its own physical drive, so all of the volumes export in parallel without competing for the same spindles. Within a volume, VMs export one at a time, which keeps each drive writing sequentially instead of thrashing. If you have 10GBE this definitely maximizes throughput.

Step 3: export each VM into a dated slice

Each export process receives its volume number and works through that volume’s list:

<#
.SYNOPSIS
    Exports every VM on tonight's list for one archive volume into a dated slice folder.

.DESCRIPTION
    Called once per volume by startbackups.ps1. Reads volume<N>.txt (one VM name per line),
    creates VMSLICE_yyyyMMdd under that volume's slices folder, and exports each VM into it.
    Writes to the local disk when running on the storage server (IAPETUS) and to the share
    everywhere else.

    Every export is logged to exportvol<N>.log. The script exits with code 1 if any export
    failed, so the launcher can report it.

    The volume number can be passed by position (exportvolume.ps1 6) or by name
    (-VolumeNumber 6 or -volume 6).

.EXAMPLE
    .\exportvolume.ps1 6
#>
param(
    [Parameter(Mandatory, Position = 0)]
    [Alias('volume')]
    [ValidateRange(0, 9)]
    [int]$VolumeNumber,

    # Where the VM lists and logs live
    [string]$BatchFolder = 'C:\batch',

    # Leave empty to choose automatically: local disk on IAPETUS, the share everywhere else
    [string]$ArchiveRoot = ''
)

$vaultComputerName = 'IAPETUS'
$vaultShareHost    = 'iapetus-fiber'
$volumeLabel       = "vol$VolumeNumber"
$vmListPath        = Join-Path $BatchFolder "volume$VolumeNumber.txt"
$logPath           = Join-Path $BatchFolder "exportvol$VolumeNumber.log"

function Write-Log {
    param([Parameter(Mandatory)][string]$Message)

    $logLine = '{0}  {1}  {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $volumeLabel, $Message
    Write-Host $logLine
    Add-Content -LiteralPath $logPath -Value $logLine
}

# One path pattern for every volume
if (-not $ArchiveRoot) {
    if ($env:COMPUTERNAME -eq $vaultComputerName) {
        $ArchiveRoot = 'C:\archive'
    }
    else {
        $ArchiveRoot = "\\$vaultShareHost\archive"
    }
}
$sliceRoot = [System.IO.Path]::Combine($ArchiveRoot, $volumeLabel, 'Shares', "BackupVol$VolumeNumber", 'slices')
$slicePath = Join-Path $sliceRoot ('VMSLICE_{0:yyyyMMdd}' -f (Get-Date))

# Tonight's list: trimmed names, blank lines ignored
if (-not (Test-Path -LiteralPath $vmListPath)) {
    Write-Log "no list at $vmListPath, nothing to export"
    exit 0
}
$vmNames = @(Get-Content -LiteralPath $vmListPath | ForEach-Object { $_.Trim() } | Where-Object { $_ })
if ($vmNames.Count -eq 0) {
    Write-Log 'list is empty, nothing to export'
    exit 0
}

try {
    Import-Module Hyper-V -ErrorAction Stop
    # Create the slice only when there is something to put in it
    New-Item -ItemType Directory -Path $slicePath -Force -ErrorAction Stop | Out-Null
}
catch {
    Write-Log "FAILED   could not prepare the export: $($_.Exception.Message)"
    exit 1
}

Write-Log "exporting $($vmNames.Count) VMs to $slicePath"

$exportedCount = 0
$skippedCount  = 0
$failedCount   = 0

foreach ($vmName in $vmNames) {
    $vmExportPath = Join-Path $slicePath $vmName

    # Export-VM refuses to overwrite, so a rerun skips VMs already exported today
    if (Test-Path -LiteralPath $vmExportPath) {
        Write-Log "skipped  $vmName (already exported today)"
        $skippedCount++
        continue
    }

    $exportTimer = [System.Diagnostics.Stopwatch]::StartNew()
    try {
        Export-VM -Name $vmName -Path $slicePath -ErrorAction Stop
        Write-Log ('exported {0} in {1:hh\:mm\:ss}' -f $vmName, $exportTimer.Elapsed)
        $exportedCount++
    }
    catch {
        Write-Log "FAILED   $vmName after $('{0:hh\:mm\:ss}' -f $exportTimer.Elapsed): $($_.Exception.Message)"
        $failedCount++

        # Remove the partial export so a rerun can try this VM again cleanly
        if (Test-Path -LiteralPath $vmExportPath) {
            Remove-Item -LiteralPath $vmExportPath -Recurse -Force -ErrorAction SilentlyContinue
        }
    }
}

Write-Log "finished: $exportedCount exported, $skippedCount skipped, $failedCount failed"
if ($failedCount -gt 0) {
    exit 1
}

Each export process does four things:

  • Resolves the destination. If it’s running on the storage server itself (IAPETUS), it writes to the local disk. From any other host, it writes to the matching share on iapetus-fiber.
  • Creates a dated slice, named VMSLICE_yyyyMMdd, but only when there is something to export.
  • Exports each VM on the list into that slice, one after another, logging each result and how long it took.
  • Makes reruns safe. A VM already exported today is skipped, and a failed export’s partial folder is removed, so running the script again picks up only what’s missing.

The result is a simple, browsable layout: volume, then date, then VM.

\\iapetus-fiber\archive\vol2\Shares\backupvol2\slices\
├── VMSLICE_20261010\
├── VMSLICE_20261011\
└── VMSLICE_20261012\
    └── MyVM\
        ├── Snapshots\
        ├── Virtual Hard Disks\
        └── Virtual Machines\

There’s no catalog to rebuild and no proprietary format to decode. A backup is just a Hyper-V export sitting in a folder.

Step 4: prune expired slices and optimize

A maintenance script runs on the storage server itself, where each backup drive is mounted as a folder under C:\archive. It deletes slices that have aged out, keeps one slice per month for the long view, then queues deduplication on every volume.

<#
.SYNOPSIS
    Prunes expired VM backup slices on each archive volume, keeps one slice per month,
    and queues Data Deduplication jobs.

.DESCRIPTION
    Runs on the storage server (IAPETUS). For every archive volume:
      1. Reads each VMSLICE_yyyyMMdd folder and takes its date from the folder name.
      2. Keeps the first slice in each month that actually contains exports.
      3. Deletes slices older than the volume's retention window (except monthly keepers).
      4. Queues garbage collection if anything was deleted and the drive is low on space,
         then queues an optimization job.

    Every action is appended to one log. The script exits with code 1 if anything failed,
    so Task Scheduler's "Last Run Result" shows a problem.

    Task Scheduler runs it with no arguments; every parameter below is optional.

.EXAMPLE
    .\manage-volumes.ps1 -WhatIf
    Dry run: logs what would be deleted and queued, changes nothing.

.EXAMPLE
    .\manage-volumes.ps1 -MonthlyKeepMonths 24
    Normal run, but monthly keepers older than 24 months are pruned too.
#>
[CmdletBinding(SupportsShouldProcess)]
param(
    # Folder where each backup drive is mounted as vol0 ... vol9
    [string]$ArchiveRoot = 'C:\archive',

    # One log for every run; each line is time-stamped and appended
    [string]$LogPath = 'C:\batch\manage-volumes.log',

    # How many months of monthly keepers to hold; 0 keeps them forever
    [ValidateRange(0, 1200)]
    [int]$MonthlyKeepMonths = 0,

    # Start-DedupJob -Memory is a PERCENTAGE of physical memory per job (not GB). Ten jobs can run at once.
    [ValidateRange(1, 100)]
    [int]$DedupMemoryPercent = 15,

    # After deleting, queue garbage collection right away only when free space is below this percentage.
    # Otherwise the weekly scheduled garbage collection reclaims the space.
    [ValidateRange(0, 100)]
    [int]$GarbageCollectBelowFreePercent = 20
)

# Days of nightly slices to keep on each volume. 0 = never prune.
$volumeRetentionDays = [ordered]@{
    0 = 110
    1 = 120
    2 = 30
    3 = 365
    4 = 120
    5 = 180
    6 = 100
    7 = 90
    8 = 60
    9 = 120
}

function Write-Log {
    param([Parameter(Mandatory)][string]$Message)

    $logLine = '{0}  {1}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
    Write-Host $logLine
    # -WhatIf:$false so the log is still written during a dry run
    Add-Content -LiteralPath $LogPath -Value $logLine -WhatIf:$false
}

function Test-SliceHasContent {
    param([Parameter(Mandatory)][string]$SlicePath)

    $firstItem = Get-ChildItem -LiteralPath $SlicePath -Force -ErrorAction SilentlyContinue |
        Select-Object -First 1
    return ($null -ne $firstItem)
}

$today = (Get-Date).Date
$firstOfThisMonth = $today.AddDays(1 - $today.Day)
if ($MonthlyKeepMonths -gt 0) {
    $monthlyKeeperCutoff = $firstOfThisMonth.AddMonths(-$MonthlyKeepMonths)
}
else {
    $monthlyKeeperCutoff = [datetime]::MinValue
}

$deletedTotal = 0
$problemTotal = 0
$runMode = if ($WhatIfPreference) { ' (dry run)' } else { '' }
Write-Log "==== Volume maintenance started$runMode"

foreach ($volumeEntry in $volumeRetentionDays.GetEnumerator()) {
    $volumeNumber    = $volumeEntry.Key
    $retentionDays   = $volumeEntry.Value
    $volumeLabel     = "vol$volumeNumber"
    $volumeRoot      = [System.IO.Path]::Combine($ArchiveRoot, $volumeLabel)
    $slicesPath      = [System.IO.Path]::Combine($volumeRoot, 'Shares', "BackupVol$volumeNumber", 'slices')
    $deletedOnVolume = 0

    # ---- Prune expired slices ------------------------------------------------------------
    if ($retentionDays -le 0) {
        Write-Log "$volumeLabel  pruning disabled"
    }
    elseif (-not (Test-Path -LiteralPath $slicesPath)) {
        Write-Log "$volumeLabel  PROBLEM  slices folder not found: $slicesPath"
        $problemTotal++
    }
    else {
        # 1. Every slice, dated from its folder name (VMSLICE_yyyyMMdd), not its timestamps
        $slices = foreach ($sliceFolder in Get-ChildItem -LiteralPath $slicesPath -Directory -Filter 'VMSLICE_*') {
            $sliceDate = [datetime]::MinValue
            $dateText  = $sliceFolder.Name.Substring('VMSLICE_'.Length)
            $isDated   = [datetime]::TryParseExact($dateText, 'yyyyMMdd',
                             [System.Globalization.CultureInfo]::InvariantCulture,
                             [System.Globalization.DateTimeStyles]::None, [ref]$sliceDate)
            if ($isDated) {
                [pscustomobject]@{ Path = $sliceFolder.FullName; Date = $sliceDate }
            }
            else {
                Write-Log "$volumeLabel  skipped (name has no date): $($sliceFolder.FullName)"
            }
        }

        # 2. Monthly keepers: the first slice in each month that actually contains exports
        $monthlyKeeperPaths = @{}
        foreach ($monthGroup in ($slices | Group-Object { $_.Date.ToString('yyyy-MM') })) {
            $firstPopulatedSlice = $monthGroup.Group |
                Sort-Object Date |
                Where-Object { Test-SliceHasContent -SlicePath $_.Path } |
                Select-Object -First 1

            if ($firstPopulatedSlice -and $firstPopulatedSlice.Date -ge $monthlyKeeperCutoff) {
                $monthlyKeeperPaths[$firstPopulatedSlice.Path] = $true
            }
        }

        # 3. Delete slices past the retention window that are not monthly keepers
        $retentionCutoff = $today.AddDays(-$retentionDays)
        $expiredSlices = $slices |
            Where-Object { $_.Date -lt $retentionCutoff -and -not $monthlyKeeperPaths.ContainsKey($_.Path) } |
            Sort-Object Date

        foreach ($expiredSlice in $expiredSlices) {
            if ($PSCmdlet.ShouldProcess($expiredSlice.Path, 'Delete expired backup slice')) {
                try {
                    Remove-Item -LiteralPath $expiredSlice.Path -Recurse -Force -ErrorAction Stop
                    Write-Log "$volumeLabel  deleted  $($expiredSlice.Path)"
                    $deletedOnVolume++
                }
                catch {
                    Write-Log "$volumeLabel  PROBLEM  could not delete $($expiredSlice.Path): $($_.Exception.Message)"
                    $problemTotal++
                }
            }
            else {
                Write-Log "$volumeLabel  would delete  $($expiredSlice.Path)"
            }
        }

        $deletedTotal += $deletedOnVolume
        Write-Log ("{0}  {1} slices, {2} monthly keepers, {3} deleted (keeping {4} days)" -f
            $volumeLabel, @($slices).Count, $monthlyKeeperPaths.Count, $deletedOnVolume, $retentionDays)
    }

    # ---- Deduplication -------------------------------------------------------------------
    if ($PSCmdlet.ShouldProcess($volumeRoot, 'Queue deduplication jobs')) {
        # Free space and savings, for the log and the garbage-collection decision.
        # If this lookup fails, optimization is still queued below.
        $volumeStatus = 'free space unknown'
        $freePercent  = $null
        try {
            $dedupVolume = Get-DedupVolume -Volume $volumeRoot -ErrorAction Stop
            if ($dedupVolume.Capacity -gt 0) {
                $freePercent  = [math]::Round(100 * $dedupVolume.FreeSpace / $dedupVolume.Capacity, 1)
                $volumeStatus = "$freePercent% free, $($dedupVolume.SavingsRate)% dedup savings"
            }
        }
        catch {
            Write-Log "$volumeLabel  could not read deduplication status: $($_.Exception.Message)"
        }

        try {
            if ($deletedOnVolume -gt 0 -and $null -ne $freePercent -and $freePercent -lt $GarbageCollectBelowFreePercent) {
                Start-DedupJob -Volume $volumeRoot -Type GarbageCollection -Priority Low `
                    -Memory $DedupMemoryPercent -ErrorAction Stop | Out-Null
                Write-Log "$volumeLabel  queued garbage collection ($volumeStatus)"
            }

            Start-DedupJob -Volume $volumeRoot -Type Optimization -Priority Low `
                -Memory $DedupMemoryPercent -ErrorAction Stop | Out-Null
            Write-Log "$volumeLabel  queued optimization ($volumeStatus)"
        }
        catch {
            Write-Log "$volumeLabel  PROBLEM  deduplication job: $($_.Exception.Message)"
            $problemTotal++
        }
    }
}

Write-Log "==== Finished: $deletedTotal slices deleted, $problemTotal problems$runMode"
if ($problemTotal -gt 0) {
    exit 1
}

A few details make it safe to leave unattended:

  • Retention is set per volume in the table at the top, from 30 days on volume 2 to a full year on volume 3.
  • Dates come from the folder name, not file timestamps, so copying a drive’s contents can’t throw retention off.
  • One slice per month is kept: the first one that actually contains exports, which is normally the full backup on the 1st. It stays indefinitely, or for MonthlyKeepMonths months.
  • Only VMSLICE_yyyyMMdd folders are touched, so nothing else in the share can be deleted by accident.
  • -WhatIf is a dry run. It logs what would be deleted and changes nothing.

Once pruning is done, the script queues an optimization job on every volume at low priority. If a drive is under 20% free after deletions, it queues garbage collection first, so the freed space comes back the same night instead of at the weekly scheduled run.

Finding a backup to restore

Finding a backup takes seconds, because every slice is an ordinary folder named for its VM. From the network (or anywhere with access to the storage server), open the archive folder that holds the volume mount points. Here is mine, \\iapetus\c$\archive:

The archive folder on the storage server, one mount point per backup drive

Each volN folder is a mount point for one physical backup drive.

Let’s say a Zabbix update went sideways and I want to roll back to a known working copy. Like any backup product, search for it: type the VM’s name into the search box at the archive root.

Search results for zabbix across every backup volume

Every saved copy of the Zabbix VM, newest first.

The results come up in a few seconds, and they tell you most of what you need:

  • When each copy was taken. The slice name in the Folder column (VMSLICE_20260927, for example) is the night the backup ran. Date modified is when that export finished, which on my server is just after midnight.
  • Where it lives. The tag in the VM’s name, V8SUN, means volume 8, backed up on Sundays.
  • Retention at work. Weekly copies cover the last 60 days, which is volume 8’s retention window. Before that, only the copy from the 1st of each month remains: those are the monthly restore points the cleanup script never deletes.

Pick the newest copy from before the bad update and double-click it to open that VM’s export.

Inside one slice: the standard Hyper-V export layout

Snapshots, Virtual Hard Disks and Virtual Machines: a complete Hyper-V export.

It’s ready to restore or copy. Import it as shown in the next section, or copy a VHDX out of Virtual Hard Disks and mount it to pull out individual files.

Prefer a prompt? The same search in PowerShell lists every copy across all volumes, newest first:

Get-ChildItem '\\iapetus\c$\archive\vol*\Shares\BackupVol*\slices\VMSLICE_*\*Zabbix*' -Directory |
    Sort-Object { $_.Parent.Name } -Descending |
    Select-Object @{ Name = 'Slice'; Expression = { $_.Parent.Name } }, Name, FullName

Restoring a machine

Because every slice is a standard Hyper-V export, recovery uses the same tools as everyday administration.

  • Find it. Browse to the volume and date. The VM’s folder is right there, by name.
  • Restore a copy (the normal case). Import with -Copy so the VM’s files move to production storage and the backup stays untouched.
  • Run it in place (the emergency case). Register the VM directly from the backup volume and start it. Keep in mind the VM is now writing to that slice, so that restore point changes as it runs. Once the crisis is over, Move-VMStorage can migrate it to production storage while it stays online.
  • Recover from a pulled drive. Connect the drive to any Windows Server with Data Deduplication installed, then import the export or mount its virtual disks to pull files out.
$slicePath = '\\iapetus-fiber\archive\vol2\Shares\backupvol2\slices\VMSLICE_20261011'
$vmConfig  = Get-ChildItem "$slicePath\MyVM\Virtual Machines\*.vmcx"

# Normal restore: copy to production storage, leave the backup untouched
Import-VM -Path $vmConfig.FullName -Copy -GenerateNewId `
          -VirtualMachinePath 'D:\Hyper-V' -VhdDestinationPath 'D:\Hyper-V\MyVM'

# Emergency: run it in place on the storage server (next section)

Running in place, then moving it live

When something critical is down, the fastest recovery is to run the VM straight from the backup drive and deal with storage afterwards. Here is the whole round trip for the Zabbix VM, which normally runs on hv-host01: bring the backup up on IAPETUS, where it already sits on the backup drive, then live-migrate it home while it keeps running. Everything below runs from a PowerShell session on hv-host01.

1. Take the broken VM out of the way. Turn it off so the restored copy doesn’t collide with it on the network, then remove its registration. Remove-VM leaves the broken VM’s virtual hard disks in place if you want to inspect them later, and it frees the VM’s ID for the trip back.

$vmName    = 'Zabbix 743 Ubuntu (V8SUN)'
$slicePath = 'C:\archive\vol8\Shares\BackupVol8\slices\VMSLICE_20260927'   # the path as IAPETUS sees it

Stop-VM -Name $vmName -TurnOff -ErrorAction SilentlyContinue
Remove-VM -Name $vmName -Force

2. Register and start the backup copy on IAPETUS. The import runs on IAPETUS, so it uses IAPETUS’s local path to the slice, not the network share. Nothing is copied: the VM runs from the backup drive.

Invoke-Command -ComputerName IAPETUS -ArgumentList $vmName, $slicePath -ScriptBlock {
    param($restoreVmName, $restoreSlicePath)
    $vmConfig = Get-ChildItem "$restoreSlicePath\$restoreVmName\Virtual Machines\*.vmcx"
    Import-VM -Path $vmConfig.FullName -Register
    Start-VM -Name $restoreVmName
}

In Hyper-V Manager, the same step is Connect to Server, then IAPETUS, then Import Virtual Machine. Type the slice path as IAPETUS sees it and choose “Register the virtual machine in-place (use the existing unique ID)”.

Two things can stop the import or the start:

  • Virtual switch names. The VM’s network adapter expects a virtual switch with the same name it had on hv-host01. Give IAPETUS a switch with that name, and both the import and the trip back go cleanly.
  • Different processors. The backup was captured in saved state on hv-host01’s processor. If IAPETUS has a different processor generation, the saved state won’t resume and the live migration back will be refused. Discard the saved state (the VM then cold-boots) and turn on processor compatibility before starting it; that setting can’t change while the VM runs.
# Only if the start fails on a different processor generation
Invoke-Command -ComputerName IAPETUS -ArgumentList $vmName -ScriptBlock {
    param($restoreVmName)
    Remove-VMSavedState -VMName $restoreVmName
    Set-VMProcessor -VMName $restoreVmName -CompatibilityForMigrationEnabled $true
    Start-VM -Name $restoreVmName
}

3. Live-migrate it home while it runs. Once things are stable, move the running VM and its storage from IAPETUS back to hv-host01. Use a fresh folder, since the broken VM’s disks are still in the old one.

Move-VM -ComputerName IAPETUS -Name $vmName -DestinationHost 'hv-host01' -IncludeStorage `
        -DestinationStoragePath "D:\Hyper-V\$vmName (restored)"   # D:\Hyper-V = VM storage on hv-host01

The move is carried out by IAPETUS on your behalf, so a little setup has to be in place first:

  • Live migration on both hosts. Run Enable-VMMigration on each, and set Kerberos authentication with Set-VMHost -VirtualMachineMigrationAuthenticationType Kerberos.
  • Constrained delegation for IAPETUS. In Active Directory, IAPETUS’s computer account must be trusted to delegate the cifs and Microsoft Virtual System Migration Service services on hv-host01. Without it, start the same move from a Remote Desktop session on IAPETUS using CredSSP authentication.

In Hyper-V Manager, connected to IAPETUS from hv-host01: right-click the VM, choose Move, then “Move the virtual machine”, enter hv-host01, and pick “Move the virtual machine’s data to a single location”.

[Screenshot (optional): the Move wizard in Hyper-V Manager.]

While it runs from the backup drive, keep three things in mind:

  • Performance is lower. The disks are read through deduplication from a backup drive. That’s fine for an emergency, but move the VM once things are stable.
  • The cleanup script doesn’t know it’s in use. A slice near the end of its retention window is due for deletion. On volume 8, which keeps 60 days, a 57-day-old slice is three nights from cleanup, so move the VM before then. Monthly slices are never deleted.
  • That restore point is used up. Once the VM runs from a slice, the slice no longer holds the original backup, and the move removes its disks from the slice. Every other night’s copy is untouched.

Off-site storage follows the same logic. Each drive is self-contained, so rotating one out means unplugging it. Moving the whole drive keeps the deduplication savings intact.

Copying individual files off the volume reads them back at full size, so volume 6 alone would need about 77 TB on the way out. If you need a copy rather than the drive itself, Windows Server Backup can back up a deduplicated volume in its optimized form.

How well does it work?

The design meets every requirement on the list, with two small caveats:

Requirements scorecard. Scheduled backups: met. Portable media: met. Find and restore a VM: met. Run a VM in place: met, though running it alters that night’s slice. Easy off-site storage: met by design, rotation is manual today.

What works especially well

  • Nothing stands between you and recovery. Restoring requires only Hyper-V. There’s no backup server, catalog, agent, or license to bring back first, which is exactly what you want on the worst day.
  • Every restore point stands alone. There’s no incremental chain to replay. A bad slice affects one night, not every night after it.
  • Storage efficiency rivals incremental backups. Deduplication delivers the savings without the complexity.
  • Retention is tiered. Each volume keeps its own window of nightly slices, plus a month-start slice for the long view.
  • The failure domain is one drive. Independent volumes mean a dead disk costs a slice of history, not the whole archive.
  • Parallelism matches the hardware. Parallel across drives, sequential within each drive.
  • Configuration documents itself. The backup policy is in the VM name, visible to anyone who opens Hyper-V Manager.

Trade-offs and next steps

I’m not a PowerShell expert, and the first version of these scripts was written to work, not to win awards. A second look turned up the usual suspects: errors nobody saw, copy-and-paste slips, and a few things done on every run that only needed doing once. The scripts above include those fixes. What’s left are trade-offs worth knowing about.

  1. Watch the logs. Each script writes a log in C:\batch and exits with an error code when something fails, so Task Scheduler’s Last Run Result flags a bad night. Running the launcher with -Wait adds a one-line result for every volume.
  2. Give deduplication room to work. Deduplication is a post-process. New exports land at full size until the optimization job gets to them, and by default (the Default and Hyper-V usage types) files younger than three days are skipped entirely. The nightly optimization jobs still honor that minimum age, so each drive needs headroom for several nights of un-optimized exports. Lowering MinimumFileAgeDays on the backup volumes shrinks that window.
  3. Cap the monthly archive. Month-start slices are never deleted by default. Setting MonthlyKeepMonths in the cleanup script (24 keeps two years) puts a ceiling on them.
  4. Plan for application consistency. A live export captures the VM in saved state, which resumes cleanly on compatible hardware. If that saved state has to be discarded (on a host with a different processor family, for example), the disks are only crash-consistent. For SQL Server and other databases, exporting from a production checkpoint gives an application-consistent copy.
  5. Test restores on a schedule. Compare-VM reports incompatibilities without importing anything, and a periodic test import proves the backups actually come back.
  6. Treat the off-site drive as the air gap. The backup shares are writable from the Hyper-V hosts, so anything that compromises a host can reach the backups. Regularly rotating a drive offline and off-site is what protects against ransomware.

Closing thoughts

This isn’t a replacement for an enterprise backup suite in every environment. But for a Hyper-V shop with spare drives and some PowerShell, it covers every requirement on my list: scheduled full-image backups, drives you can pull and read anywhere, point-and-import restores, the option to run a VM straight from the backup, and an off-site plan that amounts to copying VMs to a USB drive to extract from the data center.

The core idea is simple. Let Hyper-V do the capturing, let deduplication do the storing, and keep the format so plain that recovery never depends on the backup tool itself.

Steven Hill is a senior software engineer and architect with Dial Exascale Computing.