Thoughts on software development, technology, and other random philosophical matters.
Daryl Wright /     (7 mins)

Many Linux users have long depended on rsync as a versatile copy and backup tool. I've used it myself the few times I've had Linux as my daily driver OS. Since switching back to Windows, I couldn't find a good enough tool that worked the same way as rsync, so I stopped observing International Backup Awareness Day for a while (this day, not this one).

I eventually got tired of the anxiety of possibly losing all my data one day, and decided to do an honest search for a Windows alternative to rsync. It didn't take long for me to find Robocopy. After playing around with Robocopy for a bit, I realized it was too complicated on the surface to use as a standalone tool. So, I created a simple backup script to encapsulate some of that complexity, and I've been using it until this writing. It looks similar to the following:

(TL;DR: Skip ahead to the improved backup script)

#Requires -RunAsAdministrator

function SyncFiles {
    param (
        [Parameter(Mandatory)]
        [string]$SourcePath,
        [Parameter(Mandatory)]
        [string]$DestinationPath,
        [Parameter(Mandatory)]
        [string]$LogPath
    )

    robocopy "$SourcePath" "$DestinationPath" *.* /mir /fft /zb /w:5 /eta /unilog:"$LogPath" #/l
}

$drivePath = "G:"

$driveExists = Test-Path $drivePath

if ($driveExists) {
    try {
        Write-Host "Syncing files..."

        SyncFiles -SourcePath (Get-Item ~\Documents).FullName -DestinationPath "$drivePath\Documents" -LogPath "$drivePath\Documents.log"
        SyncFiles -SourcePath (Get-Item ~\Pictures).FullName -DestinationPath "$drivePath\Pictures" -LogPath "$drivePath\Pictures.log"

        Write-Host "Sync completed!"
    }
    catch {
        Write-Host "A problem was encountered while syncing files:"
        Write-Host $_
    }
}
else {
    Write-Host "Backup drive is not present. Please mount the $drivePath drive."
}

It's simple and does the job, but I think I can make some improvements to it. Before that, let's go over how this script works, starting with the robocopy command.

According to the docs, the robocopy command has the following syntax:

robocopy <source> <destination> [<file>[ ...]] [<options>]

Robocopy has a ton of options. I'll quickly summarize the few options I'm using in the backup script.

OptionDescription
/mirMirrors a directory tree (equivalent to /e plus /purge). Using this option with the /e option and a destination directory, overwrites the destination directory security settings.
/fftAssumes FAT file times (two-second precision).
/zbCopies files in restartable mode. If file access is denied, switches to backup mode. (combines /z and /b options)
/w:5Specifies the wait time between retries, in seconds. The default value of n is 30 (wait time 30 seconds). In this case n is 5.
/etaShows the estimated time of arrival (ETA) of the copied files.
/unilog:"$LogPath"Writes the status output to the log file as Unicode text (overwrites the existing log file). $LogPath is a self-explanatory variable.
/lSpecifies that files are to be listed only (and not copied, deleted, or time stamped). (Serves as a 'dry run' but commented out in my example)

In the script, I'm syncing the $SourcePath files to the $DestinationPath via /mir, ensuring deleted items in the former are purged in the latter. /zb ensures that the copy continues where it left off if interrupted (/z), and that file permissions are ignored when copying (/b). The /b option is why we need #Requires -RunAsAdministrator at the top of the script, which ensures it doesn't run unless in Administrator mode. If a file fails to copy, /w:5 ensures that we don't wait the default 30 seconds to retry. /eta doesn't display in the terminal with my script, likely because I'm logging to a file with /unilog. I don't use /l unless I'm testing the script, so it gets left commented out. Finally, /fft ensures files can be copied correctly between different file systems (more on this here).

There are some problems with the way I call robocopy here. The /eta options is useless while I'm logging to a file. It can be safely omitted. w:5 is good for ensuring there isn't a long wait time between retries of failed copies, but without a retry limit, the copy will be reattempted 1,000,000 times. At 5 seconds per retry, that's 57 days 20 hours and 53 minutes. The /r:<n> option can be used to limit retries to a more reasonable amount, like 5 for instance. The script also follows symbolic links by default. This may not be desirable behaviour. The /sl option will ensure that only the link itself will be copied.

There are some other issues as well. I'm currently copying files over regardless of whether they were modified or not. I'm also needlessly copying files such as those in node_modules directories. These issues will either use up unnecessary space and/or lengthen the time of the backup. The following options will remedy these issues:

OptionDescription
/xd <directory>[ ...]Excludes directories that match the specified names and paths.
/xoExcludes older files.

With the above improvements, our robocopy command now looks like the following as encapsulated in a function:

function SyncFiles {
    param (
        [Parameter(Mandatory)]
        [string]$SourcePath,
        [Parameter(Mandatory)]
        [string]$DestinationPath,
        [Parameter(Mandatory)]
        [string]$LogPath
    )

    robocopy "$SourcePath" "$DestinationPath" *.* /mir /fft /zb /sl /xo /xd "node_modules" /w:5 /r:5 /unilog:"$LogPath"
}

It's a bit silly to hardcode node_modules in this function, so let's add a parameter to the SyncFiles function. While we're at it, we can also add a parameter to execute robocopy in dry run mode.

function SyncFiles {
    param (
        [Parameter(Mandatory)]
        [string]$SourcePath,
        [Parameter(Mandatory)]
        [string]$DestinationPath,
        [Parameter(Mandatory)]
        [string]$LogPath,
        [Parameter()]
        [string[]]$ExcludedDirs,
        [Parameter()]
        [switch]$DryRun
    )

    robocopy "$SourcePath" "$DestinationPath" *.* `
      /mir /fft /zb /sl /xo /xd $ExcludedDirs /w:5 /r:5 /unilog:"$LogPath" `
      $(if ($DryRun) { "/l" } else { "" })
}

Much better, if a bit opinionated. The rest of the script is a lot more opinionated, since I'd prefer to make my backup experience as painless as possible. Otherwise, I might just procrastinate on backing my data up until the worst happens. While I might not change the files I want backed up very frequently, I may want to backup to a different drive or path than usual. All I have to do is make the backup path a parameter that is supplied to the script and we have the finished product as shown below.

#Requires -RunAsAdministrator
param(
[Parameter(Mandatory)]
[string]$BackupPath
)
# You can use this function on its own instead of using the entirety of this script
function SyncFiles {
param (
[Parameter(Mandatory)]
[string]$SourcePath,
[Parameter(Mandatory)]
[string]$DestinationPath,
[Parameter(Mandatory)]
[string]$LogPath,
[Parameter()]
[string[]]$ExcludedDirs,
[Parameter()]
[switch]$DryRun
)
robocopy "$SourcePath" "$DestinationPath" *.* `
/mir /fft /zb /sl /xo /xd $ExcludedDirs /w:5 /r:5 /unilog:"$LogPath" `
$(if ($DryRun) { "/l" } else { "" })
}
$backupPathExists = Test-Path $BackupPath
if ($backupPathExists) {
try {
Write-Host "Syncing files..."
# Add or remove directories as necessary
SyncFiles `
-SourcePath (Get-Item ~\Documents).FullName `
-DestinationPath "$BackupPath\Documents" `
-LogPath "$BackupPath\Documents.log" `
-ExcludedDirs "node_modules"
SyncFiles `
-SourcePath (Get-Item ~\Pictures).FullName `
-DestinationPath "$BackupPath\Pictures" `
-LogPath "$BackupPath\Pictures.log"
SyncFiles `
-SourcePath (Get-Item ~\Downloads).FullName `
-DestinationPath "$BackupPath\Downloads" `
-LogPath "$BackupPath\Downloads.log" `
-DryRun
Write-Host "Sync completed!"
}
catch {
Write-Host "A problem was encountered while syncing files:"
Write-Host $_
}
}
else {
Write-Host "Backup path $BackupPath can not be found."
}

You can run this script, after customizing the directories you'd like backed up, with a simple command.

# Backup to a drive...
.\EasyRobocopyBackup.ps1 -BackupPath G:

# ... or a path
.\EasyRobocopyBackup.ps1 -BackupPath .\some_path

# Remember to run the script as an Administrator

If you need to test backing up to a new directory, you can use the -DryRun switch on the SyncFiles function. I could have added this as another parameter to the main script, but I figured once I've established the directories I want to have backed up, I would seldom need it.

And there you have it, an easy template to help make backing data up on Windows a bit easier. Let me know in the Gist if you experience any issues or have any suggestions for improvements. And always remember...

Every day is International Backup Awareness Day -- Jeff Atwood, Coding Horror, 2009

Daryl Wright's picture
About Daryl Wright

Daryl is an experienced technology advisor who specializes in creating innovative software solutions and solving novel problems. He has spent over a decade providing technical expertise in various industries and is well versed in a wide variety of programming languages, tools, and frameworks. Daryl is the owner of Golden Path Technologies, a proud father, husband, and dog parent.

22 posts Miramichi, New Brunswick, Canada https://goldenpath.ca Github Reddit Mastodon Bluesky