Export files to SFTP in Node.js with ssh2-sftp-client
Upload a local file with put(), download it with get(), and compare the bytes before reporting
success. This walkthrough uses ssh2-sftp-client with SSH key authentication and a temporary local
OpenSSH server, so you can try the complete transfer without an existing SFTP account.
Authenticate the server as well as the user
SFTP transfers files over SSH. Your client key identifies you to the server; the server’s host key
identifies the server to you. The underlying ssh2 client
auto-accepts host keys unless you provide a hostVerifier, so the example explicitly checks one.
For an existing server, obtain its host-key fingerprint from its administrator over a trusted
channel. With hostHash: 'sha256', ssh2 passes the verifier a lowercase, 64-character hex digest
of the raw SSH public-key bytes. That is a different encoding from the SHA256: base64 fingerprint
printed by OpenSSH tools. Do not learn the expected value from an unverified first connection.
Create the client project
The commands below use Bash, Node.js 26.8.1, Yarn 4.12.0, ssh-keygen, and Docker Engine 28 or newer
on Linux. The client is pinned to ssh2-sftp-client 12.1.1.
Node runs the TypeScript file directly; no build step is
needed. The example holds both copies of the file in memory, so use a small file that comfortably
fits in RAM.
Run this in a directory where node-sftp-demo does not already exist. The && chain stops setup
if creating or entering the directory fails. The empty lockfile keeps this a separate Yarn project,
including when its parent is another project.
mkdir node-sftp-demo &&
cd node-sftp-demo &&
printf '{"private":true,"type":"module"}\n' > package.json &&
touch yarn.lock &&
yarn add --exact ssh2-sftp-client@12.1.1 &&
ssh-keygen -q -t ed25519 -N '' -f client_key &&
printf 'Hello over SFTP.\n' > example.txt
Stay in this directory for the remaining commands. client_key is a disposable, unencrypted private
key for this local exercise. Only its public half goes into the container. Keep real private keys
out of source control and use your server’s approved authentication setup.
Start a local SFTP server
Run the following in the first terminal. It installs OpenSSH inside an Ubuntu 24.04 container,
creates the demo user, and gives that user a private, writable /home/demo/incoming directory.
ForceCommand internal-sftp restricts sessions
to SFTP; password login and forwarding are disabled.
docker run --rm --name node-sftp-demo \
--publish 127.0.0.1::22 \
--mount "type=bind,src=$PWD/client_key.pub,dst=/client_key.pub,readonly" \
ubuntu:24.04 bash -euc '
if ! command -v sshd >/dev/null; then
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends openssh-server
fi
useradd -m -s /bin/sh demo
passwd -d demo
install -d -m 700 -o demo -g demo /home/demo/.ssh /home/demo/incoming
install -m 600 -o demo -g demo /client_key.pub /home/demo/.ssh/authorized_keys
mkdir -p /run/sshd
ssh-keygen -q -t ed25519 -N "" -f /etc/ssh/demo_host_key
exec /usr/sbin/sshd -D -e -f /dev/null \
-o HostKey=/etc/ssh/demo_host_key \
-o PasswordAuthentication=no \
-o KbdInteractiveAuthentication=no \
-o PermitRootLogin=no \
-o AllowUsers=demo \
-o DisableForwarding=yes \
-o "Subsystem=sftp internal-sftp" \
-o ForceCommand=internal-sftp
'
Leave this running after it prints a line beginning Server listening on. If it exits first,
resolve the printed error before continuing. Docker chooses an available host port and
binds it to loopback. The server’s files
and host key live only in this container and disappear when it is removed.
Open a second terminal in node-sftp-demo. Read the assigned port and copy the host’s public key
through your local Docker connection, which is the trusted administrative channel for this example:
docker port node-sftp-demo 22/tcp &&
docker cp node-sftp-demo:/etc/ssh/demo_host_key.pub server_host_key.pub
The first command prints an address such as 127.0.0.1:32768. Use its actual port below. Copying the
public key replaces any existing server_host_key.pub in this demo directory. Each new container
has a new host key, so repeat this step when you recreate it.
Upload and verify one file
Save this as transfer.ts. The local path and full remote file path are command-line arguments.
The remote parent directory must already exist and allow your account to write and read files.
import { readFile } from 'node:fs/promises'
import Client from 'ssh2-sftp-client'
let stage = 'configuration'
async function main(): Promise<void> {
const [localPath, remotePath] = process.argv.slice(2)
const { SFTP_HOST, SFTP_PORT, SFTP_USERNAME, SFTP_KEY_FILE, SFTP_HOST_SHA256 } = process.env
const port = Number(SFTP_PORT ?? '22')
if (
!localPath || !remotePath || !SFTP_HOST || !SFTP_USERNAME || !SFTP_KEY_FILE ||
!/^[a-f0-9]{64}$/.test(SFTP_HOST_SHA256 ?? '') ||
!Number.isInteger(port) || port < 1 || port > 65535
) {
throw new Error('Provide two paths, SFTP settings, and a verified SHA-256 hex fingerprint')
}
stage = 'reading local files'
const original = await readFile(localPath)
const privateKey = await readFile(SFTP_KEY_FILE)
const sftp = new Client()
try {
stage = 'connect'
await sftp.connect({
host: SFTP_HOST,
port,
username: SFTP_USERNAME,
privateKey,
hostHash: 'sha256',
hostVerifier: (fingerprint: string) => fingerprint === SFTP_HOST_SHA256,
readyTimeout: 10000,
})
stage = 'upload'
await sftp.put(original, remotePath)
stage = 'download verification'
// Version 12.1.1 can return an empty array for a zero-byte download.
const downloaded = Buffer.from(await sftp.get(remotePath))
if (!original.equals(downloaded)) {
throw new Error('Downloaded bytes differ from the uploaded bytes')
}
} finally {
await sftp.end()
}
console.log(`Verified ${original.length} bytes at ${remotePath}`)
}
main().catch(() => {
console.error(`SFTP transfer failed during ${stage}; check the settings and server logs`)
process.exitCode = 1
})
put() replaces an existing remote file at the chosen path. Use a destination that is safe to
overwrite. This is a direct write: an interrupted transfer can leave a truncated or partial file,
and a failed verification does not roll it back. The downloaded copy stays in memory; the script
does not create or overwrite a local download file. Buffer.from() normalizes the empty-download
result as well as ordinary binary buffers, so a valid zero-byte file passes verification.
The package documents put() and get().
Here they run sequentially on one connection. The finally block calls end() after success or
failure, and failures give the process a nonzero exit status. The connection’s readyTimeout bounds
the SSH handshake, not the whole transfer; use a job-level deadline if running this unattended.
Set the connection details in the second terminal. Replace 32768 with the port Docker printed.
The command decodes the trusted public key’s base64 field and hashes those bytes, rather than
hashing the text of the .pub file:
export SFTP_HOST=127.0.0.1 SFTP_PORT=32768 SFTP_USERNAME=demo SFTP_KEY_FILE=client_key
SFTP_HOST_SHA256=$(node --input-type=module -e '
import { createHash } from "node:crypto"
import { readFileSync } from "node:fs"
const [, key] = readFileSync("server_host_key.pub", "utf8").trim().split(/\s+/)
console.log(createHash("sha256").update(Buffer.from(key, "base64")).digest("hex"))
') &&
export SFTP_HOST_SHA256 &&
yarn node transfer.ts example.txt /home/demo/incoming/example.txt
For the supplied file, success prints:
Verified 17 bytes at /home/demo/incoming/example.txt
To transfer your own small file, replace the two path arguments, quoting paths that contain spaces. A successful comparison establishes that the server returned the bytes you uploaded at that moment; it does not establish backup durability or that another process has consumed the file.
Diagnose a failed transfer
| Failure stage | What to check |
|---|---|
configuration | Supply both paths, an integer port from 1 to 65535, and the fingerprint in hex form. |
reading local files | Check that the source and private key exist and are readable. |
connect | Check the port, trusted host key, username, and authorized client key. A host-key change needs verification with the administrator. |
upload | Check the remote parent directory and write permissions. The script does not create directories. |
download verification | Check read permissions and whether another process moved or changed the remote file. |
A server disconnect can fail either transfer step. Inspect the server terminal before retrying, and
check for a partial destination file. Do not remove the host verifier to work around a connection
error. For an existing server, use paths as seen by that SFTP account; a chrooted account may see
/incoming/example.txt even when its administrator sees a longer filesystem path.
Stop the local server
When finished, run this in the second terminal:
docker stop node-sftp-demo
Because the server was started with --rm, stopping it deletes the container and its uploaded
files. Your local project, sample file, and disposable client key remain in node-sftp-demo.
