Backup Hyper-V VMs with PowerShell

Backing up your Hyper-V VM environment using PowerShell has never been easier! PowerShell allows you to backup your VMs, log the results and powershell-tips-and-trickseven email you a report with a few easy commands.

In this case, I have two physical hosts, VH-SERVER01 and VH-SERVER02. Each of them has quite a bit of HD space so for the purposes of recovery from a simple hardware failure, back up the VMs on one host to the other is a great, simple way to go.

I created shares on each server for the other to use as a repository. VH-SERVER0xexports. I map the Z: drive to this share for simplicity’s sake.

One caveat that I found is that a Domain Controller VM will not export to a SMB share, even when it is mapped with a drive letter. I researched this and found all sorts of suggestions about permissions related to compluter accounts and despite my efforts, I was unable to resolve the direct export. So, for the DCs, I export them locally and then move the files to the same share where the other exports are sent.

After all the exports are done, I call the script Get-DirStats.ps1 to calculate the sizes of the exported files. I output the results to a file. The script code for that is located below the main script. I then use the great command, Send-MailMessage to email me the outputted results! Note that you need to have an SMTP server that will allow you to route email. Since this is a corporate client, I have an Exchange Receive Connector that will allow me to send SMTP traffic without authentication.

Some helpful links:

The script code!

#deletes and adds Z: as a persistent drive since this script needs to run whether someone is logged in or not

Get-PSDrive Z | Remove-PSDrive
New-PSDrive -Name “Z” -PSProvider “FileSystem” -Root “\VH-SERVER01exports” -Persist
Remove-Item D:ExpADSERVER02 -Recurse
New-Item -ItemType directory -Path “D:ExpADSERVER02”

#define export paths

$ExportPath_D = “D:ExpADSERVER02”
$ExportPath_Z = “Z:”
$date = Get-Date
$date = $date.ToString(“yyyy-MM-dd”)

#Deletes files-folders that are older than 20 days

$limit = (Get-Date).AddDays(-20)
$path = $ExportPath_Z

# Delete files older than the $limit.

Get-ChildItem -Path $path -Recurse -Force | Where-Object { !$_.PSIsContainer -and $_.CreationTime -lt $limit } | Remove-Item -Force

# Delete any empty directories left behind after deleting the old files.

Get-ChildItem -Path $path -Recurse -Force | Where-Object { $_.PSIsContainer -and (Get-ChildItem -Path $_.FullName -Recurse -Force | Where-Object { !$_.PSIsContainer }) -eq $null } | Remove-Item -Force -Recurse

New-Item -ItemType directory -Path “$ExportPath_Z$date”

#Exports are here

Export-VM -Name “ADSERVER02” -Path $ExportPath_D

Move-item $($ExportPath_D) Z:$($date)

Export-VM -Name “SERVER03″,”SERVER04″,”SERVER05″,”SERVER06” -Path $ExportPath_Z$date

#Report creation and email

c:scriptsGet-DirStats.ps1 -Path Z:$($date) -Every >C:Backup-LogsVH-SERVER02-$date.csv

Send-MailMessage -From “Backups <>” -To “Alerts <>” -Subject “VH-SERVER02 Backup Status for $date” -Body “This is the VM backup report for VH-SERVER02.” -Attachments “c:backup-logsVH-SERVER02-$date.csv” -Priority High -dno onSuccess, onFailure -SmtpServer “MAIL.YOURDOMAIN.COM”


Outputs file system directory statistics..DESCRIPTION
Outputs file system directory statistics (number of files and the sum of all file sizes) for one or more directories.

Specifies a path to one or more file system directories. Wildcards are not permitted. The default path is the current directory (.).

.PARAMETER LiteralPath
Specifies a path to one or more file system directories. Unlike Path, the value of LiteralPath is used exactly as it is typed.

Outputs statistics for a directory but not any of its subdirectories.

Outputs statistics for every directory in the specified path instead of only the first level of directories.

.PARAMETER FormatNumbers
Formats numbers in the output object to include thousands separators.

Outputs a summary object after all other output that sums all statistics.

[String[]] $LiteralPath,
[Switch] $Only,
[Switch] $Every,
[Switch] $FormatNumbers,
[Switch] $Total

begin {
$ParamSetName = $PSCmdlet.ParameterSetName
if ( $ParamSetName -eq “Path” ) {
$PipelineInput = ( -not $PSBoundParameters.ContainsKey(“Path”) ) -and ( -not $Path )
elseif ( $ParamSetName -eq “LiteralPath” ) {
$PipelineInput = $false

# Script-level variables used with -Total.
[UInt64] $script:totalcount = 0
[UInt64] $script:totalbytes = 0

# Returns a [System.IO.DirectoryInfo] object if it exists.
function Get-Directory {
param( $item )

if ( $ParamSetName -eq “Path” ) {
if ( Test-Path -Path $item -PathType Container ) {
$item = Get-Item -Path $item -Force
elseif ( $ParamSetName -eq “LiteralPath” ) {
if ( Test-Path -LiteralPath $item -PathType Container ) {
$item = Get-Item -LiteralPath $item -Force
if ( $item -and ($item -is [System.IO.DirectoryInfo]) ) {
return $item

# Filter that outputs the custom object with formatted numbers.
function Format-Output {
process {
$_ | Select-Object Path,
@{Name=”Files”; Expression={“{0:N0}” -f $_.Files}},
@{Name=”Size”; Expression={“{0:N0}” -f $_.Size}}

# Outputs directory statistics for the specified directory. With -recurse,
# the function includes files in all subdirectories of the specified
# directory. With -format, numbers in the output objects are formatted with
# the Format-Output filter.
function Get-DirectoryStats {
param( $directory, $recurse, $format )

Write-Progress -Activity “Get-DirStats.ps1” -Status “Reading ‘$($directory.FullName)'”
$files = $directory | Get-ChildItem -Force -Recurse:$recurse | Where-Object { -not $_.PSIsContainer }
if ( $files ) {
Write-Progress -Activity “Get-DirStats.ps1” -Status “Calculating ‘$($directory.FullName)'”
$output = $files | Measure-Object -Sum -Property Length | Select-Object `
@{Name=”Path”; Expression={$directory.FullName}},
@{Name=”Files”; Expression={$_.Count; $script:totalcount += $_.Count}},
@{Name=”Size”; Expression={$_.Sum; $script:totalbytes += $_.Sum}}
else {
$output = “” | Select-Object `
@{Name=”Path”; Expression={$directory.FullName}},
@{Name=”Files”; Expression={0}},
@{Name=”Size”; Expression={0}}
if ( -not $format ) { $output } else { $output | Format-Output }

process {
# Get the item to process, no matter whether the input comes from the
# pipeline or not.
if ( $PipelineInput ) {
$item = $_
else {
if ( $ParamSetName -eq “Path” ) {
$item = $Path
elseif ( $ParamSetName -eq “LiteralPath” ) {
$item = $LiteralPath

# Write an error if the item is not a directory in the file system.
$directory = Get-Directory -item $item
if ( -not $directory ) {
Write-Error -Message “Path ‘$item’ is not a directory in the file system.” -Category InvalidType

# Get the statistics for the first-level directory.
Get-DirectoryStats -directory $directory -recurse:$false -format:$FormatNumbers
# -Only means no further processing past the first-level directory.
if ( $Only ) { return }

# Get the subdirectories of the first-level directory and get the statistics
# for each of them.
$directory | Get-ChildItem -Force -Recurse:$Every |
Where-Object { $_.PSIsContainer } | ForEach-Object {
Get-DirectoryStats -directory $_ -recurse:(-not $Every) -format:$FormatNumbers

end {
# If -Total specified, output summary object.
if ( $Total ) {
$output = “” | Select-Object `
@{Name=”Path”; Expression={“<Total>”}},
@{Name=”Files”; Expression={$script:totalcount}},
@{Name=”Size”; Expression={$script:totalbytes}}
if ( -not $FormatNumbers ) { $output } else { $output | Format-Output }