I've tried a few different options for FTP with PowerShell and the best one so far is WinSCP with its .NET assembly.

$winscpPath = "C:\Program Files (x86)\WinSCP"

try {
    [Reflection.Assembly]::LoadFile("$winscpPath\WinSCPnet.dll") | Out-Null
} catch {
    Write-Error "$winscpPath\winSCPnet.dll failed to load, you must have WinSCP 5.20+ installed with the .NET assembly."

$sessionOptions = New-Object WinSCP.SessionOptions
$sessionOptions.Protocol = [WinSCP.Protocol]::Ftp
$sessionOptions.HostName = "..."
$sessionOptions.UserName = "..."
$sessionOptions.Password = "..."
$session = New-Object WinSCP.Session
$session.ExecutablePath = "$winscpPath\WinSCP.exe"

# Secret sauce ...
    param($Sender, $EventArgs)

	Set-StrictMode -Version Latest
    $ErrorActionPreference = "Stop"
    $nl = [Environment]::NewLine

	Write-Progress -Status $EventArgs.FileName -Id 1 -Activity ("File $($EventArgs.Operation) @ {0:N0} KB/s" -f $($EventArgs.CPS / 1024)) -PercentComplete ($EventArgs.FileProgress * 100)

$winscpResult = $null
try {
	$transferOptions = New-Object WinSCP.TransferOptions
	$transferOptions.TransferMode = [WinSCP.TransferMode]::Binary

	# This is the key part
	$winscpResult = $session.PutFiles("C:\Sample.txt", "/", $false, $transferOptions)
} catch {
	Write-Error $_
}	finally {
    Write-Progress -Id 1 -Completed -Activity "Completed" -Status "Completed"

    if ($session -ne $null) {

But here are some important notes:

  • For the first parameter make sure the filename you provide includes a full path or WinSCP will throw an exception.
  • For the second parameter, if you pass in an empty string "" WinSCP.NET will fail with a vague syntax error exception. If you accidentally pass in "" instead of "/" it will keep connecting to your FTP server over and over while not actually transferring anything. That took me so long to work out:
  • Be sure to call .Dispose() otherwise you'll sometimes go into Task Manager later and find a bunch of WinSCP.exe still sitting in memory and doing nothing.

But where it really shines is when you're doing this and trigger of file transfers on a half dozen servers and want to track the progress on all of them at once. First put run the above as an Invoke-Command -AsJob and make a $jobList to have a list of all the jobs. Then:

Write-Host "Waiting for all servers to complete"
While ($runningJobList = $jobList | Where-Object State -eq Running) {
    $runningJobList | %{
	    $runningJobLocation = $_.Location
	    $childJob = $_.ChildJobs[0]

	    if ($childJob.Progress.Count -gt 0) {
            $childJob.Progress[$childJob.Progress.Count - 1] | %{
                $childJobProgressCompleted = if ($_.RecordType -eq "Processing") { $false } else { $true }
                $childJobProgressId = $childJob.Id + $_.ActivityId
                $childJobProgressParentId = $_.ParentActivityId
                if ($childJobProgressParentId -ne -1) {
                    $childJobProgressParentId += $childJob.Id
                try {
                    Write-Progress -Activity "$($runningJobLocation): $($_.Activity)" -Status $_.StatusDescription -CurrentOperation $_.CurrentOperation -PercentComplete $_.PercentComplete -SecondsRemaining $_.SecondsRemaining -Id $childJobProgressId -ParentId $childJobProgressParentId -Completed:$childJobProgressCompleted
                } catch { }

As you can see in this code I'm giving the progress bar an Id that is a combination of the job number and activity, allowing you to have multiple stacked progress bars per server. If you need more you can always multiply the job number by 10 or 100 to create more headroom.