How to deploy to remote sites with PowerShell (no DFS required)

Steven van Dijk headshot
Steven van Dijk|Updated August 15, 2023
Illustration of block with Powershell logo
Illustration of block with Powershell logo

Distributed File System (DFS) is a great way to ensure your remote sites can access resources over a local connection instead of a slow wide-area network (WAN) link. DFS is particularly convenient if you need to deploy packages to remote sites. However, DFS may not be an option for some organizations. If DFS isn’t an option for you, and your boss decided a new Corvette with a company logo painted on the side was more important than paying for a faster WAN link, you’ve still got options. Here’s how to use PowerShell as a DFS substitute.

Using PowerShell as an alternative to DFS

Sometimes DFS isn’t the answer for your environment. Maybe your remote sites are all on the same subnet, so namespaces can’t resolve to the closest server. Or maybe ‘DFS’ are the initials of the Ex who broke your heart and… you know… you’re just not ready. Whatever the reason, here’s an alternative to DFS which saves bandwidth when deploying to remote sites using PowerShell and Robocopy.

Step 1: Find a local computer to cache install files on

The first part of our deployment will run ‘Get Closest Server.ps1’ on our target machine to find a close server we can cache the install files on. Here's the script:

$ServerListFile = "\\path\to\ServerList.txt" #Text file containing hostnames of possible cache servers, one hostname per line $ServerList = Get-Content $ServerListFile #Get list of servers $PingJobs = ForEach ($Server in $ServerList) { Test-Connection -ComputerName $Server -Count 20 -Delay 1 -AsJob -ErrorAction SilentlyContinue #Ping the server (asjob so they run simultaneously) } $PingList = Receive-Job $PingJobs -Wait | #Receive the jobs and wait for them to finish Select-Object Address, ResponseTime | #Select only Addresses (hostname) and response time Where-Object ResponseTime -NE $null #Exclude failed pings (null response time) $GroupedPingList = $PingList | Group-Object -Property Address #Group ping results by address (hostname) so we can average the multiple pings of each address $PingAverages = ForEach ($Address in $GroupedPingList) { $Avg = $Address | Select-Object -ExpandProperty Group | Measure-Object -Average -Property ResponseTime | #Average the multiple pings of each address Select-Object -ExpandProperty Average [PSCustomObject]@{ #Save average into a custom object with hostname Hostname = $Address.Name Average = $Avg } } $SortedPingAverages = $PingAverages | Sort-Object -Property Average #sort list, fastest response times first $ClosestServer = $SortedPingAverages | Select-Object -First 1 #grab the fastest one, and save to variable Write-Output $ClosestServer [Environment]::SetEnvironmentVariable("PDQClosestServer", "$($ClosestServer.Hostname)", "Machine") #Set environment variable with the hostname of the closest server

This script does the following:

  • Retrieves a text file with a list of possible cache servers, one hostname per line.

  • Pings each server in the list and finds the average ping time per hostname. Sorts the list, and saves the hostname with the lowest ping to an environment variable on the target machine.

In my testing, this has been a reliable way to find a server on the local network. Any machine at a different site should have a significantly higher ping time than the one onsite.

Step 2: Copy files from the Repository to the remote cache are target device

The next hurdle is getting the install files copied over from the Repository to the remote cache location, which will then transfer the files to the target machine. Here's the script:

#Variables commented out here so they can be declared in the package with a dot-source. If running the script by itself, uncomment the variable declarations here. #$InstallFolder = "Testing" #Installer folder in repository #$InstallFiles = "example installer.exe","test.ini","win10.msu" #Files to be copied enclosed in quotes, separated by a comma, or "*" for entire folder #$RepoPath = "c$\PDQRepo" #Repo path on cache server and target machine. This should probably only be set once. #Uncomment the correct ONE: #$PDQRepository = "\\PDQServer\$RepoPath" # <--- Set to PDQ Repository path if inserting this in a PowerShell step as ps1 file #$PDQRepository = "$(Repository)" # <--- Use if pasting in powershell step #RobocopyFiles #Robocopies install files from Repository to cache server, and then from cache server to target machine function RobocopyFiles { Start-Sleep (Get-Random -Minimum 0 -Maximum 20) #Random delay to avoid collisions with other deployments while (Test-Path "\\$env:PDQClosestServer\$RepoPath\$InstallFolder\*.lock") #Check for other simultaneous copies. { Write-Output "Lockfile exists from another copy. Waiting 10 seconds..." Start-Sleep 10 #Wait 10 seconds } New-Item -Path "\\$env:PDQClosestServer\$RepoPath\$InstallFolder\$env:COMPUTERNAME-robocopy.lock" -ItemType File -Force #Create lock file #Copy Files from repository to closest server robocopy "$PDQRepository\$InstallFolder" "\\$env:PDQClosestServer\$RepoPath\$InstallFolder" $InstallFiles /XO /E /R:100 /W:10 Remove-Item -Path "\\$env:PDQClosestServer\$RepoPath\$InstallFolder\$env:COMPUTERNAME-robocopy.lock" -Force #Remove lock file #Then copy files from closest server to target machine robocopy "\\$env:PDQClosestServer\$RepoPath\$InstallFolder" "\\localhost\$RepoPath\$InstallFolder" $InstallFiles /XO /E /R:100 /W:10 if ($lastExitCode -eq 1 ) { exit 0 } #Error Code 1 means files were copied successfully } #CheckFilesAndCopy #Starting point for copying files. Checks to verify that the files aren't already on the target machine. #Triggers function RobocopyFiles to do the actual copying function CheckFilesAndCopy { if (-Not ($env:PDQClosestServer)) # If PDQClosestServer environment variable doesn't exist { Write-Output "PDQClosestServer environment variable is empty or doesn't exist" exit 1 } else { if ($InstallFiles -match "\*") # If InstallFiles is all files { $InstallFiles = (Get-ChildItem "$PDQRepository\$InstallFolder\$InstallFiles" -Name) } foreach ($File in $InstallFiles) { if (Test-Path "\\localhost\$RepoPath\$InstallFolder\$File") { #If install path already exists on target machine, we might not need to copy #If files are the same by name and date... if ((Compare-Object "\\localhost\$RepoPath\$InstallFolder\$File" "$PDQRepository\$InstallFolder\$File" -Property Name, LastWriteTime) -eq $null) { #Files don't need to be recopied Write-output "$File is the same, no need to copy" } else { #File doesn't match, they should be recopied. Write-output "$File doesn't match. Copying as normal." RobocopyFiles } } else { Write-Output "$File not found on localhost. Copying as normal." RobocopyFiles } } } }

Let's take a look at the different functions of this PowerShell script and break down what each one does.

The ‘CheckFilesAndCopy’ function does a few different things:

  • Checks to make sure the ‘PDQClosestServer’ environment variable exists. If not, something went wrong in the previous step, so it exits.

  • Checks to see if the install files already exist on the target machine. If they do, no reason to copy again.

  • If the files don’t exist or are out of date, the ‘RobocopyFiles’ function is called to copy the files.

Function ‘RobocopyFiles’ uses Robocopy to copy the install files:

  • Avoids collisions in case multiple deployments are attempting to copy from the repository to the same cache server at the same time. Does this by having a random delayed start, and by checking for lock files and creating a lock file.

  • Robocopy from the central repository to the cache server. Uses the /xo flag so it doesn’t recopy the files if they have the same name and modified date.

  • Robocopy from the cache server to the target machine. Uses the /xo flag so it doesn’t recopy the files if they have the same name and modified date.

Step 3: Run the install command

Now that the files have been copied to the target machine, you can run your silent install string. Just update your variables in the ‘Run Install.ps1’ script.

$InstallFolder = "Testing" #Installer folder in repository $RepoPath = "c$\PDQRepo" #Repo path on cache server and target machine. This should probably only be set once. $InstallCommand = 'example installer.exe' $InstallParameters = '/S' Start-Process -Wait -WorkingDirectory "\\localhost\$RepoPath\$InstallFolder" -FilePath $InstallCommand -ArgumentList $InstallParameters # Remove-Item -Path "\\localhost\$RepoPath\$InstallFolder\" -recurse -Force #Optional: cleanup install folder to save disk space

Step 4: Clean up local install files (optional)

If you’d like to clean up files on the target machine, just add this Remove-Item line after the Start-Process cmdlet which runs the install command:

Remove-Item -Path '\\localhost\$RepoPath\$InstallFolder\' -Recurse -Force

Configuring PDQ Deploy to use the closest repository server

I like to separate the ‘Get Closest Server’ script into its own Deploy package so that I can run it separately to see where machines are. You can do that with an Inventory collection that looks at environment variables.*

'Get Closest Server' Package using PowerShell an alternative to DFS

Then I create a second deployment with three steps:

  1. Nested ‘Get Closest Server'

  2. Precache Installer Files

  3. Run Install Command

'Precache Install Files' Package Step
'Run Install' Package Step with PowerShell as an alternative to deploying with DFS

*Bonus: Inventory report to display the closest servers of your target machines

After running ‘Get Closest Server’ and doing an environment variable scan (included in the default scan profile), your target machines will have an environment variable of the closest server. You can create a report to show you the information like this:

Closest Server Report   Columns
Closest Server Report   Filters

Where there's a will, there's usually a way (with PowerShell)

DFS is usually the best option for spreading out network load, but in situations when that’s not an option, you can use this method an alternative to DFS and script your deployments to use a cache server. I hope this method saves you some headaches copying files to your remote site in the Siberian wilderness with that spotty satellite connection, or your site in rural Vermont still running on dial-up.

If videos are more your thing, here’s the webcast Lex and I did on this subject. And, if you've made it this far and you're not already using PDQ Deploy and Inventory, I commend you for your determination and grit. However, a trial is just a click away. Check out Deploy and Inventory for free for 14 days. And if you'd rather manage remote devices with an agent-based solution, PDQ Connect might be the perfect solution for you.

Steven van Dijk headshot
Steven van Dijk

Steven has over 8+ years of experience in system administration and software engineering. Steven codes exclusively on ErgoDox keyboards.

Related articles