Use volume shadow copy (VSS) and use it for the backup, so we can access all needed files. The VSS is deleted after we backed everything up.
Example:
# Helper: create a VSS snapshot for a drive letter ("C:", "D:", etc.) and return its root device path
function New-ShadowRoot {
param([Parameter(Mandatory)] [string]$DriveLetter) # e.g., "C:"
$driveSpec = ($DriveLetter.TrimEnd('\') + '\')
$class = Get-WmiObject -List Win32_ShadowCopy
$create = $class.Create($driveSpec, 'ClientAccessible')
if ($create.ReturnValue -ne 0) { throw "VSS create failed for $DriveLetter: $($create.ReturnValue)" }
$shadow = Get-WmiObject Win32_ShadowCopy | Where-Object { $_.ID -eq $create.ShadowID }
if (-not $shadow) { throw "VSS snapshot object not found for $DriveLetter." }
# e.g. \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyXX\
$root = ($shadow.DeviceObject.TrimEnd('\') + '\')
[pscustomobject]@{
Drive = $DriveLetter.ToUpper()
Shadow = $shadow
Root = $root
}
}
# Helper: convert a normal absolute path to its snapshot-based path using the given shadow root
function Convert-ToShadowPath {
param(
[Parameter(Mandatory)] [string]$Path, # e.g. C:\Users
[Parameter(Mandatory)] [string]$ShadowRoot # e.g. \\?\GLOBALROOT\Device\HarddiskVolumeShadowCopyXX\
)
# Require absolute "X:\..." style
if ($Path -notmatch '^[A-Za-z]:\\') { throw "Source must be an absolute path like C:\..., got: $Path" }
$rel = $Path.Substring(3) # strip "C:\" => "Users\..."
if ([string]::IsNullOrWhiteSpace($rel)) { return $ShadowRoot } # root of drive
return (Join-Path $ShadowRoot $rel)
}
# Cache snapshots per drive so we only create one per volume in a run
$shadowCache = @{} # key: "C:", value: object with .Shadow and .Root
# Track created snapshots for cleanup
$createdShadows = New-Object System.Collections.Generic.List[object]
try {
foreach ($task in $config.backups) {
$source = $task.source # e.g. "C:\Users" or "D:\Data"
$destination = $task.destination # e.g. "\\192.168.0.2\backup\Users"
$excludeFiles = $task.excludeFiles
$excludeDirs = $task.excludeDirs
# Determine drive of source (e.g. "C:")
if ($source -notmatch '^[A-Za-z]:\\') {
throw "Source path must start with a drive letter: $source"
}
$drive = $source.Substring(0,2).ToUpper()
# Get or create snapshot root for this drive
if (-not $shadowCache.ContainsKey($drive)) {
$snap = New-ShadowRoot -DriveLetter $drive
$shadowCache[$drive] = $snap
$createdShadows.Add($snap) | Out-Null
}
$srcRoot = $shadowCache[$drive].Root
# Transform source to snapshot path
$shadowSource = Convert-ToShadowPath -Path $source -ShadowRoot $srcRoot
# Build robocopy command
$robocopyCommand = @(
'robocopy',
('"{0}"' -f $shadowSource), # source from VSS
('"{0}"' -f $destination), # UNC or local
'/MIR',
'/COPY:DT',
'/DCOPY:T',
'/XJ',
'/NP',
'/MT:12',
"/R:$($config.retryCount)",
"/W:$($config.waitTime)",
"/LOG+:`"$($config.rclogPath)`""
)
# If you prefer restartable mode with backup fallback:
# $robocopyCommand += '/ZB'
# Or keep your original "/B" only:
if ($isAdmin) { $robocopyCommand += '/B' }
# If destination is UNC/NAS, add /FFT to tolerate 2-second timestamp granularity
if ($destination -like '\\*') { $robocopyCommand += '/FFT' }
# Exclusions
foreach ($dir in ($excludeDirs | Where-Object { $_ })) {
$robocopyCommand += '/XD'
$robocopyCommand += ('"{0}"' -f $dir)
}
foreach ($file in ($excludeFiles | Where-Object { $_ })) {
$robocopyCommand += '/XF'
$robocopyCommand += ('"{0}"' -f $file)
}
$command = $robocopyCommand -join ' '
"Executing (VSS): $command" | Out-File $logPath -Append
# Use cmd.exe so robocopy gets its normal exit codes & logging behavior
$rc = (cmd.exe /c $command).ExitCode
# Optional: normalize robocopy exit codes (0–7 generally mean success of varying kinds)
if ($rc -gt 7) {
"Robocopy returned errorlevel $rc for source $source" | Out-File $logPath -Append
}
}
}
finally {
# Clean up all snapshots we created
foreach ($snap in $createdShadows) {
try { [void]$snap.Shadow.Delete() } catch {}
}
}
Use volume shadow copy (VSS) and use it for the backup, so we can access all needed files. The VSS is deleted after we backed everything up.
Example: