Scripting SFTP Transfers from PowerShell: Patterns That Hold Up Unattended
SFTP is the protocol your partners, banks, and vendors actually run. As we established in the first article of this series, it is exactly the protocol PowerShell does not speak natively. So every "PowerShell SFTP script" in production is really one of two things: a script using a community module, or a script driving an external command-line client. Both work. Both also have failure modes that only show up when the script runs unattended at night, which is precisely when transfer scripts run.
This article builds both patterns properly. It covers the module route with its session lifecycle and the command-line route with batch files and exit-code discipline. It covers the host-key handling that separates a job that runs for years from one that hangs silently on its first scheduled run. It is part of our PowerShell Automation series, and it pairs directly with handling credentials safely. Every example here assumes secrets come from somewhere sane, never from a string in the script.
The Ground Rules for Unattended SFTP
A script that works beautifully in your console can still be broken for scheduled use. The difference is prompts. Interactively, the SFTP client asks questions — "password?", "the authenticity of host ... can't be established, continue?" — and you answer them without thinking. Run the same script from Task Scheduler and there is no one to answer. The job does not fail; it waits, forever, invisibly. So unattended SFTP has three ground rules:
- Nothing may ever prompt. Authentication must come from a key or stored credential, and the client must be configured to fail rather than ask.
- Every step must prove it succeeded. Modules prove it with catchable errors; external clients prove it with exit codes. Either way, the script checks — silence is not success.
- The server's identity must be established in advance. SFTP clients verify the server's host key — the server's cryptographic identity card — against a local record. That record has to exist before the first scheduled run, for the account the job runs as.
Keep those three in mind and both routes below become straightforward. Ignore them and you will meet the classic mystery: "the script works when I run it, but the scheduled task just sits there." (It is almost always rule one or rule three.)
Route One: SFTP Through a Community Module
A community module adds SFTP cmdlets to PowerShell — Posh-SSH is the long-standing example. The programming model is a session: connect once, perform operations through the session, disconnect. The skeleton looks like this:
# Credential comes from an encrypted file, not from the script -
# see the credentials article for how this file is created
$cred = Import-Clixml -Path 'D:\jobs\secure\sftp-cred.xml'
$session = New-SFTPSession -ComputerName 'sftp.example.com' -Credential $cred
try {
# Upload the staged file into the partner's inbox folder
Set-SFTPItem -SessionId $session.SessionId `
-Path 'D:\staging\report.csv' -Destination '/inbox'
# Download their acknowledgment file back
Get-SFTPItem -SessionId $session.SessionId `
-Path '/outbox/ack.csv' -Destination 'D:\incoming'
}
finally {
Remove-SFTPSession -SessionId $session.SessionId | Out-Null
}
The shape matters more than the cmdlet names. Open the session, do the work inside try, and close the session in finally. PowerShell guarantees to run that block whether the work succeeded or threw an error. Orphaned sessions from crashed scripts are a real nuisance (some servers limit concurrent connections per account), and finally is the cure.
The module route's great advantage is that everything stays in PowerShell's world. Remote directory listings come back as objects you can filter with Where-Object; failures surface as PowerShell errors your catch blocks can inspect. If your job needs remote-side logic — "download only the files we have not seen, then delete them from the server" — this route expresses it naturally.
Its obligations are operational. The module must be installed, at a consistent version, on every machine that runs the job — including rebuilt servers and machines without internet access. On machines without internet access, you stage the module files rather than pulling them from the online gallery. Make the script check its own prerequisite with Get-Module -ListAvailable and fail with a clear message when the module is missing. And treat host keys with the same seriousness as route two. An SFTP module verifies the server's key on connection, and production jobs should verify against a fingerprint you recorded deliberately. Obtain it from the server's administrator or read it off the console of the server itself. Never auto-accept whatever the network offers on the first run.
Route Two: Driving sftp.exe in Batch Mode
Modern Windows ships an OpenSSH client, so sftp.exe is very likely already on your machine — Get-Command sftp confirms it. The unattended pattern has two pieces: a batch file listing the transfer commands, and a PowerShell wrapper that runs the client and checks the result.
# 1. Write the batch file: the commands sftp will execute in order
$batch = @"
cd /inbox
lcd D:\staging
put report_*.csv
bye
"@
Set-Content -Path 'D:\jobs\upload.batch' -Value $batch -Encoding ascii
# 2. Run the client non-interactively and capture its output
$output = & sftp -b 'D:\jobs\upload.batch' `
-i 'D:\jobs\secure\transfer_key' `
-o 'BatchMode=yes' `
transfer@sftp.example.com 2>&1
# 3. Judge the run by its exit code, and keep the output for the log
if ($LASTEXITCODE -ne 0) {
throw "sftp exited with code $LASTEXITCODE : $output"
}
Every flag earns its place. -b names the batch file, which makes the whole session scripted. The command cd changes the remote directory, and lcd changes the local one. The command put uploads, and bye ends the session cleanly. -i points at the private key used to authenticate — key setup is covered in generating and storing SSH keys. -o 'BatchMode=yes' is the no-prompts rule made enforceable: if anything would have asked a question, the client fails immediately instead of hanging. The 2>&1 folds the client's error messages into the captured output so your log shows why a run failed. The variable $LASTEXITCODE — the automatic variable holding the last external program's exit code — is checked on the very next line. That is because external programs do not throw PowerShell errors.
Two behaviors of batch mode are worth knowing before they surprise you. First, sftp aborts the batch at the first command that fails — usually what you want, since later commands often depend on earlier ones. When a step is genuinely optional (say, deleting a previous file that may not exist), prefix that line with a hyphen — -rm /inbox/old.csv. A failure there will not abort the run. Second, a wildcard put that matches nothing is an error, not a no-op. So decide the "no files today" question in PowerShell, before you build the batch, as shown in the file-operations article.
One refinement worth adopting once the basics work: upload under a temporary name, then rename. Receiving systems often watch their inbox folder and grab files the moment they appear — including files still arriving. The batch-file version of the courtesy rename is two lines: put report.csv /inbox/report.csv.part followed by rename /inbox/report.csv.part /inbox/report.csv. The rename is a single operation on the server, so the watcher never sees a half-written report.csv. It is the remote twin of the local staging pattern covered earlier in this series, and partners running automated pickup will quietly love you for it.
Remember: for external clients, $LASTEXITCODE is the only truth. Output text can look calm while the transfer failed, and PowerShell will happily continue past a failed external command unless you check. One unchecked exit code is how a job "succeeds" for six weeks without sending a single file.
The same pattern with a vendor CLI
Route two is not tied to OpenSSH. Any command-line transfer client that returns honest exit codes slots into the identical wrapper — build the command list, run, check $LASTEXITCODE. This matters in two common situations. One is when the partner requires FTPS (FTP over TLS), which the OpenSSH tools do not speak. The other is when you inherit automation built around the old console ftp client. Take sysaxftp.exe, the command-line client that ships with Sysax FTP Automation. It was designed as a drop-in replacement for that console ftp client with SFTP and FTPS added. So a scripted command list that once ran over plain FTP can run over a secure protocol. It uses the same drive-it-and-check-the-exit-code pattern from PowerShell. Whichever client you choose, the PowerShell side of the discipline is unchanged.
Host Keys: The Unattended Job's Silent Killer
SSH-family clients keep a file called known_hosts — a list of servers the machine has decided to trust, each recorded by its host key. When the client connects and the server's key is not in the list, an interactive session asks you to confirm the fingerprint. A scheduled job cannot answer, so with BatchMode=yes it fails fast (good — you will see it). Without batch mode it hangs (bad — you will not).
Here is the trap that catches nearly everyone: known_hosts is per user account. It is stored under the profile of whoever runs the client — for OpenSSH on Windows, in that account's .ssh folder. You tested the connection from your own console, so your profile has the entry. The scheduled task runs as a service account, whose profile does not. Result: works for you, fails at night. The same per-account logic applies to the private key file and, in the module world, to the module's own host-key records. That is one of several reasons scheduled jobs deserve a properly set up identity, as described in service account hygiene.
The clean fixes, in order of preference:
- Record the key deliberately for the job. Obtain the server's fingerprint out-of-band — from the partner's onboarding document or the server administrator. Then create the
known_hostsentry for the service account and verify the fingerprint matches.ssh-keyscancan fetch the key over the network to a file you then verify; the verification step is the point, since keyscan alone trusts whatever answered. - Pin a job-specific file. The option
-o 'UserKnownHostsFile=D:\jobs\secure\known_hosts'tells the client to use a file your job owns. That removes the per-profile guessing game entirely and keeps the trusted key next to the job's other configuration. - Never blind-accept. Options that auto-accept unknown keys make first runs convenient and defeat the entire protection. A connection silently redirected to an impostor server would be trusted just as smoothly. The full reasoning lives in host keys and known_hosts.
Module or External Client? The Honest Comparison
| Question | Community module | External CLI client |
|---|---|---|
| What must be installed? | The module, on every machine, at a managed version | Often nothing — sftp.exe ships with Windows; vendor CLI where FTPS is needed |
| How do failures surface? | PowerShell errors you can catch and inspect |
Exit codes plus text output — you must check $LASTEXITCODE |
| Remote listings and logic | Objects — filter, sort, decide per file | Text — fine for fixed put/get, clumsy for decisions |
| FTPS support | No — SSH-family only | Yes, with an FTPS-capable client |
| Best fit | Jobs with rich remote-side decisions | Directional batch jobs: upload these, download those |
Both are production-worthy. Pick one as the team standard, write the host-key and credential handling once, and reuse it everywhere. Consistency across jobs is worth more than any single row of that table.
A Complete Upload Function You Can Copy
Here is route two wrapped into a reusable function. It takes a list of staged files, builds the batch file, runs the client with no-prompt options, checks the exit code, and cleans up after itself. Drop it into a job and call it after your file-selection step:
function Send-SftpFiles {
param(
[Parameter(Mandatory)][System.IO.FileInfo[]]$Files,
[Parameter(Mandatory)][string]$HostName,
[Parameter(Mandatory)][string]$UserName,
[Parameter(Mandatory)][string]$KeyPath,
[Parameter(Mandatory)][string]$RemoteDir,
[string]$KnownHosts = 'D:\jobs\secure\known_hosts'
)
if ($Files.Count -eq 0) { throw 'Send-SftpFiles called with no files' }
$batchPath = Join-Path $env:TEMP "sftp_$PID.batch"
$lines = @("cd $RemoteDir")
foreach ($f in $Files) { $lines += "put `"$($f.FullName)`"" }
$lines += 'bye'
Set-Content -Path $batchPath -Value $lines -Encoding ascii
try {
$output = & sftp -b $batchPath `
-i $KeyPath `
-o 'BatchMode=yes' `
-o "UserKnownHostsFile=$KnownHosts" `
"$UserName@$HostName" 2>&1
if ($LASTEXITCODE -ne 0) {
throw "sftp exit code $LASTEXITCODE : $output"
}
return $output
}
finally {
Remove-Item -Path $batchPath -ErrorAction SilentlyContinue
}
}
Details worth imitating: the batch file name includes $PID (the current process ID) so two overlapping runs cannot clobber each other's batch files. Each put quotes the full path so file names with spaces survive. The function throws on failure so the caller's try/catch decides what a failure means for the whole job. The finally removes the batch file even on the failure path.
Timeouts: when the run neither succeeds nor fails
There is a third outcome besides success and failure: the run that never ends. A network path that silently dies mid-transfer can leave the client waiting long after any human would have given up. The call operator (&) will wait right along with it. Two defenses cover most cases. First, pass the client a connection-level timeout. For OpenSSH tools, -o 'ConnectTimeout=30' caps how long the initial connection attempt may take (in seconds). That turns an unreachable server into a fast, loggable failure. Second, put a ceiling on the whole run. Launch the client with Start-Process -PassThru, then Wait-Process -Id $p.Id -Timeout 900. If the timeout expires, stop the process, log it, and let the job fail loudly. And in Task Scheduler itself, set the task's "stop the task if it runs longer than" limit as the backstop of last resort. A stuck transfer job that blocks tomorrow's run is how one bad night becomes a bad week.
Trust, but Verify the Transfer
An exit code of zero means the client believes every command succeeded. That is strong evidence, not absolute proof, that the partner's system has the bytes you meant to send. Careful flows add one more layer. This may be a follow-up listing to confirm the remote file's presence and size. Or it may be a hash manifest convention with the partner so corruption is detectable on their side. How much verification a flow deserves depends on what a silent failure would cost. The decision framework is in integrity checking in automated flows. The broader unattended-SFTP picture is in automating SFTP transfers.
When the Script Should Not Exist
Before you productionize, one honest checkpoint. Suppose the job you just built is "upload the staged files, on a schedule, retry on failure, email me when it breaks". Suppose there is no custom logic between the steps. In that case, you have written plumbing that a dedicated tool provides as configuration. Sysax FTP Automation covers exactly that ground. Its wizard generates SFTP, FTPS, and FTP transfer tasks, its scheduler and folder monitoring trigger them, and email notifications report failures. There is no script to maintain, though its script editor (with a line-by-line debugger) is there when a task does need custom steps. Scripts earn their keep when real logic lives between the file selection and the send. When none does, letting a tool own the plumbing is the more maintainable choice.
The Checklist Before First Scheduled Run
Everything in this article compresses to a pre-flight list. The connection authenticates with a key or stored credential — nothing prompts. BatchMode (or the module equivalent) makes any would-be prompt a fast failure. The host key is recorded, verified, and readable by the account the job runs as — ideally in a job-owned UserKnownHostsFile. Every external call is followed immediately by a $LASTEXITCODE check; every module call runs inside try with the session closed in finally. Output is captured for the log. And the whole thing has been tested as the service account, not just from your own console.
Next in the series, the article on handling credentials safely in PowerShell jobs builds the secure storage these examples imported. The article on real PowerShell transfer job patterns assembles selection, sending, logging, and exit codes into the complete nightly job.
Frequently Asked Questions
My script works in my console but hangs when Task Scheduler runs it. Why?
known_hosts entry or credentials. Add -o 'BatchMode=yes' so prompts become immediate failures, then set up the key, credential, and host-key record for the service account itself.Where does known_hosts live for a scheduled job?
-o 'UserKnownHostsFile=...', kept alongside the job's other configuration and readable by the service account.How do I make sftp continue when one command in the batch fails?
-rm /inbox/old.csv. By default sftp aborts the batch at the first failing command, which is usually the safe behavior. Reserve the hyphen for genuinely optional steps like removing a file that may not exist.Can sftp.exe do FTPS?
Do I have to install a module on every server that runs the job?
From the Sysax team: we build secure file transfer software for Windows. Sysax Multi Server is an FTP, FTPS, SFTP, and HTTPS server. Sysax FTP Automation handles scheduled, scripted transfers. Free trials are on the download page.
