Copy files to and from any SSH server from Node.js, over SFTP or SCP.
Read the documentation: guides with diagrams and live demos, recipes and the API reference.
Most libraries only speak SFTP. node-scp also speaks the real SCP protocol, so the same code
works on a cloud VM, a hardened SFTP only host, an OpenWrt router running Dropbear, or a switch
that only has scp. It picks the protocol for you.
import { connect } from 'node-scp';
await using client = await connect({ host: '192.168.1.1', username: 'root', password });
await client.upload('./build', '/tmp/build', { recursive: true });- Works where SFTP does not.
protocol: 'auto'uses SFTP when the server has it and SCP otherwise. - Fast. Parallel SFTP transfers and
TCP_NODELAY, which makes many small files up to 15 times faster than other ssh2 based libraries (numbers). - Safe. Remote paths are shell quoted, file names from the server are checked against path traversal, and the CLI pins host keys.
- Modern. TypeScript, ESM and CommonJS,
AbortSignal,await using, progress events, filters, and typed errors with stable codes. - Batteries. A CLI (
npx node-scp), a GitHub Action, and drop in layers for node-scp 0.x andscp2.
npm install node-scpNode.js 22 or newer is recommended. Node 20 still works but is past its end of life.
import { readFileSync } from 'node:fs';
import { connect } from 'node-scp';
const client = await connect({
host: 'example.com',
username: 'deploy',
privateKey: readFileSync('/home/me/.ssh/id_ed25519'),
});
try {
await client.upload('./dist', '/var/www/app', { recursive: true });
await client.download('/var/log/app.log', './logs/app.log');
console.log(client.protocol); // 'sftp' or 'scp'
} finally {
await client.close();
}await using client = await connect(...) closes the connection automatically at the end of
the block. It needs Node 24+, or TypeScript 5.2+ which compiles it for older versions.
import { download, upload } from 'node-scp';
await upload('./dist', 'deploy@example.com:/var/www/app', { recursive: true, privateKey });
await download('root@192.168.1.1:/etc/config', './backup/config', { recursive: true, password });const controller = new AbortController();
const result = await client.upload('./site', '/srv/site', {
recursive: true, // copy directories, like scp -r
preserve: true, // keep times and full modes, like scp -p
concurrency: 8, // files in parallel, SFTP only (default 4)
filter: (path, entry) => !path.startsWith('node_modules') && !path.endsWith('.map'),
onProgress: (p) => console.log(`${p.transferred}/${p.total} bytes, ${p.filesCompleted} files`),
signal: controller.signal,
});
// result: { files: 42, directories: 7, bytes: 1048576 }Destinations are exact paths: upload('dist', '/srv/site') makes /srv/site a copy of dist.
The remote parent (/srv) must exist. Local parent directories are created for downloads.
Works over both protocols:
await client.writeFile('/etc/motd', 'Hello\n', { mode: 0o644 });
await client.writeFile('/tmp/big.bin', createReadStream('big.bin'), { size: 1_000_000 });
const config = await client.readFile('/etc/config/network');Over SCP a stream needs size up front, otherwise it is buffered first. When size is given,
the stream must be exactly that long on both protocols.
When the connection uses SFTP, client.fs has the usual operations. It is undefined over SCP.
if (client.fs) {
await client.fs.mkdir('/srv/app/releases/42', { recursive: true });
const entries = await client.fs.list('/srv/app/releases'); // [{ name, type, size, mtime, ... }]
await client.fs.rename('/srv/app/current.tmp', '/srv/app/current');
await client.fs.rm('/srv/app/releases/1', { recursive: true, force: true });
await client.fs.exists('/srv/app/current'); // 'file' | 'directory' | 'symlink' | 'other' | false
}client.sftp and client.ssh expose the underlying ssh2 objects for anything else.
protocol |
Behaviour |
|---|---|
'auto' (default) |
SFTP when the server offers it, otherwise SCP. |
'sftp' |
SFTP only. Fails with ERR_SFTP_UNAVAILABLE if the server has no SFTP. |
'scp' |
SCP only. Fails with ERR_SCP_UNAVAILABLE if the server cannot run scp. |
Other connection options: everything ssh2 accepts (host, port, username, password,
privateKey, passphrase, agent, readyTimeout, keepaliveInterval, hostVerifier, sock
for jump hosts, ...) plus:
| Option | Default | Meaning |
|---|---|---|
remoteOs |
'posix' |
'win32' for Windows OpenSSH servers: backslash paths, and quoting that is safe for cmd.exe and PowerShell. |
scpCommand |
'scp' |
Remote SCP command, for example /usr/bin/scp when scp is not on the remote PATH. |
noDelay |
true |
Disable Nagle's algorithm. Leave it on unless you have a reason. |
signal |
Abort the connection attempt. | |
beforeConnect |
Gets the ssh2 client before connecting, to add keyboard-interactive or banner listeners. |
readyTimeout (default 20 s) also bounds how long the server may take to answer the SFTP or SCP
handshake.
Every error is a ScpError with a stable code, the path involved, and the original error
as cause:
import { ErrorCode, isScpError } from 'node-scp';
try {
await client.download('/missing', './x');
} catch (err) {
if (isScpError(err, ErrorCode.NotFound)) console.log('no such file:', err.path);
else throw err;
}| Code | When |
|---|---|
ERR_AUTH_FAILED, ERR_CONNECTION_FAILED, ERR_TIMEOUT |
Connecting failed. |
ERR_SFTP_UNAVAILABLE, ERR_SCP_UNAVAILABLE |
The requested protocol is not offered. |
ERR_NOT_FOUND, ERR_PERMISSION_DENIED, ERR_NOT_A_DIRECTORY, ERR_IS_A_DIRECTORY, ERR_ALREADY_EXISTS |
Filesystem errors, same codes for both protocols. |
ERR_ABORTED |
The signal fired. |
ERR_CONNECTION_CLOSED, ERR_NOT_CONNECTED |
The connection went away. |
ERR_SCP_PROTOCOL |
The server broke the SCP protocol or sent an unsafe file name. |
ERR_INVALID_ARGUMENT, ERR_UNSUPPORTED, ERR_REMOTE, ERR_LOCAL |
Everything else. |
npx node-scp -r ./dist deploy@example.com:/var/www/app
npx node-scp root@192.168.1.1:/etc/config/network ./backup/
npx node-scp -r --exclude node_modules --exclude '*.map' . deploy@host:/srv/app/It works like scp: a trailing / or several sources copy into a directory. Passwords come
from NODE_SCP_PASSWORD, keys from -i, NODE_SCP_PRIVATE_KEY or ssh-agent. Pin the server
with --fingerprint SHA256:... (as printed by ssh-keygen -lf). Run npx node-scp --help for
every option.
- uses: maitrungduc1410/node-scp-async@v1
with:
host: ${{ secrets.DEPLOY_HOST }}
username: deploy
private-key: ${{ secrets.DEPLOY_KEY }}
fingerprint: ${{ secrets.DEPLOY_HOST_FINGERPRINT }}
source: dist/
target: /var/www/app
exclude: '*.map'See Deploying from GitHub Actions for every input.
- From node-scp 0.x: change the import to
node-scp/legacyand everything keeps working, then move to the new API at your pace. See Upgrading from node-scp 0.x. - From
scp2: replacerequire('scp2')withrequire('node-scp/scp2'). See Replacing scp2.
- The guide: connecting, transfers, progress, the remote filesystem, errors
- SCP or SFTP in 2026? What changed with OpenSSH 9 and when each one wins.
- Zero downtime deploys, OpenWrt and Dropbear, network devices
- How node-scp compares with ssh2-sftp-client and node-ssh
- Architecture and the API reference
Uploads to OpenSSH on the same machine, Node 22, mean of several runs (pnpm bench):
| node-scp SFTP | node-scp SCP | ssh2-sftp-client | node-ssh | |
|---|---|---|---|---|
| One 16 MiB file | 57 ms | 53 ms | 94 ms | 104 ms |
| 200 files of 4 KiB in 10 directories | 52 ms | 67 ms | 534 ms | 484 ms |
Most of the small file difference is TCP_NODELAY. Over a real network latency dominates, and
SFTP with parallel files pulls further ahead of SCP, which copies one file at a time.
pnpm install
pnpm test # unit and end to end tests, needs sftp-server and scp installed locally
pnpm test:docker # real OpenSSH and Dropbear servers through testcontainers
pnpm lint && pnpm typecheck
pnpm docs:dev # the documentation site on localhostAGENTS.md describes the workflow and conventions, ARCHITECTURE.md
how the code fits together. Add a changeset (pnpm changeset) to pull requests that change
behaviour.