node-ssh, ssh2, ssh2-promise, and ssh2-sftp-client are Node.js libraries for establishing SSH connections to remote servers. They enable programmatic execution of shell commands and/or secure file transfers (SFTP). While all are built on the same core (ssh2), they differ significantly in abstraction level, feature scope, and maintenance status. These packages are strictly for server-side (Node.js) use and cannot run in browser environments.
When you need to run commands or transfer files over SSH from a Node.js application, four main packages come up: node-ssh, ssh2, ssh2-promise, and ssh2-sftp-client. All of them are built on the same low-level foundation (ssh2), but they offer very different developer experiences. Let’s break down how they work, where they shine, and which one fits your use case.
ssh2 is the foundational, low-level SSH2 client and server implementation for Node.js. It gives you full control over every aspect of the SSH protocol — authentication, channels, streams, SFTP sessions — but requires you to manage connections, error handling, and resource cleanup manually.
// ssh2: manual connection and command execution
const { Client } = require('ssh2');
const conn = new Client();
conn.on('ready', () => {
conn.exec('uptime', (err, stream) => {
if (err) throw err;
stream.on('data', (data) => console.log(data.toString()));
stream.on('close', () => conn.end());
});
}).connect({
host: 'example.com',
username: 'user',
password: 'pass'
});
node-ssh wraps ssh2 with a promise-based API and automatic connection/resource management. It hides most of the event-driven complexity while still exposing raw streams when needed.
// node-ssh: clean, promise-based usage
const { NodeSSH } = require('node-ssh');
const ssh = new NodeSSH();
await ssh.connect({
host: 'example.com',
username: 'user',
password: 'pass'
});
const result = await ssh.execCommand('uptime');
console.log(result.stdout);
await ssh.dispose(); // auto-cleanup
ssh2-promise also wraps ssh2 with promises, but its API closely mirrors the original ssh2 structure. It doesn’t add high-level helpers like execCommand() — you still work with exec streams directly, just in a promise-friendly way.
// ssh2-promise: promise wrapper around ssh2 streams
const SSH = require('ssh2-promise');
const ssh = new SSH({
host: 'example.com',
username: 'user',
password: 'pass'
});
const stream = await ssh.exec('uptime');
let output = '';
stream.on('data', chunk => output += chunk.toString());
await new Promise(resolve => stream.on('close', resolve));
console.log(output);
ssh2-sftp-client is specialized only for SFTP file operations. It does not support running shell commands. Built on ssh2, it provides a clean promise-based API for uploading, downloading, listing, and managing remote files.
// ssh2-sftp-client: SFTP-only operations
const Client = require('ssh2-sftp-client');
const sftp = new Client();
await sftp.connect({
host: 'example.com',
username: 'user',
password: 'pass'
});
await sftp.upload('local.txt', 'remote.txt');
await sftp.download('remote.txt', 'downloaded.txt');
await sftp.end();
Only ssh2, node-ssh, and ssh2-promise support executing remote shell commands. ssh2-sftp-client cannot do this — it’s SFTP-only.
node-ssh makes it trivial with execCommand(), which returns { stdout, stderr }.ssh2 requires manual stream handling and error plumbing.ssh2-promise improves ergonomics slightly by returning a promise for the stream, but you still manage data events yourself.All four packages can handle SFTP except that ssh2-promise doesn’t provide dedicated SFTP helpers — you’d need to create an SFTP session manually using the underlying ssh2 methods.
ssh2-sftp-client excels here with methods like upload(), download(), list(), mkdir(), etc.node-ssh includes basic SFTP support via putFile(), getFile(), and requestSFTP() for advanced use.ssh2 requires you to open an SFTP session and manage file streams manually.ssh2-promise offers no high-level SFTP abstractions; you’re on your own.node-ssh provides .dispose() to cleanly close connections and prevent leaks.ssh2-sftp-client uses .end() for graceful shutdown.ssh2 and ssh2-promise require you to call .end() on the connection object yourself — easy to forget, leading to hanging processes.As of the latest npm and GitHub data:
ssh2-promise is deprecated. Its npm page states: "This package is deprecated. Please use ssh2 directly." The repository is archived, and no updates have been published in years. Do not use in new projects.ssh2, node-ssh, and ssh2-sftp-client are actively maintained, with recent releases and responsive issue tracking.You’re building a deployment script that runs git pull, npm install, and then uploads a config file.
✅ Best choice: node-ssh
Why? It handles both command execution (execCommand) and file transfers (putFile) with a consistent, promise-based API and automatic resource cleanup.
const ssh = new NodeSSH();
await ssh.connect(config);
await ssh.execCommand('cd /app && git pull');
await ssh.execCommand('cd /app && npm install');
await ssh.putFile('local-config.json', '/app/config.json');
await ssh.dispose();
Your app syncs logs or media files to a remote server via SFTP only.
✅ Best choice: ssh2-sftp-client
Why? It’s purpose-built for SFTP with a rich set of file operations, error handling, and retry logic. No bloat from unused command-execution features.
const sftp = new Client();
await sftp.connect(config);
await sftp.upload('logs/app.log', 'remote/logs/app.log');
await sftp.end();
You’re implementing a custom SSH proxy, tunneling, or non-standard authentication flow.
✅ Best choice: ssh2
Why? It exposes raw channels, streams, and protocol events. Higher-level wrappers hide these details, which you need for advanced use cases.
const conn = new Client();
conn.on('tcpip', (info, accept, reject) => {
// Handle forwarded TCP connections
});
conn.connect({ /* ... */ });
ssh2-promise❌ Do not use ssh2-promise. It’s deprecated, unmaintained, and offers no advantage over using ssh2 directly with modern async/await patterns.
| Package | Shell Commands | SFTP Support | Promise-Based | Auto Cleanup | Maintenance Status |
|---|---|---|---|---|---|
ssh2 | ✅ Full | ✅ Manual | ❌ (events) | ❌ | ✅ Active |
node-ssh | ✅ High-level | ✅ Basic | ✅ | ✅ | ✅ Active |
ssh2-promise | ✅ Manual | ❌ | ✅ (partial) | ❌ | ❌ Deprecated |
ssh2-sftp-client | ❌ | ✅ Rich | ✅ | ✅ | ✅ Active |
node-ssh if you need both command execution and occasional file transfers. It strikes the best balance between simplicity and capability.ssh2-sftp-client — it’s reliable, focused, and well-maintained.ssh2 when you need protocol-level control.ssh2-promise entirely — it’s obsolete and adds no value over direct ssh2 usage with async wrappers.Remember: all these libraries run only in Node.js — they rely on native TCP sockets and won’t work in browsers or frontend environments. If you’re a frontend developer integrating SSH into a backend service, pick the right tool based on your actual needs, not just API convenience.
Choose node-ssh if you need a balanced, high-level API for both running remote shell commands and performing basic SFTP file operations. It offers promise-based methods, automatic connection cleanup, and a clean developer experience without sacrificing access to lower-level streams when needed. Ideal for deployment scripts, remote automation, or mixed command/file workflows.
Choose ssh2 if you require fine-grained control over the SSH protocol — such as custom channel handling, port forwarding, or non-standard authentication flows. It’s the foundational library that others wrap, so it’s necessary when higher-level abstractions hide critical functionality. Be prepared to manage connections, streams, and error handling manually.
Do not choose ssh2-promise for new projects. It is officially deprecated, unmaintained, and offers no meaningful advantage over using ssh2 directly with modern async/await patterns. Existing projects should migrate to either ssh2 (for low-level control) or node-ssh (for high-level convenience).
Choose ssh2-sftp-client if your use case involves only SFTP file operations (upload, download, list, etc.) and you don’t need to execute shell commands. It provides a rich, promise-based API specifically designed for file management over SFTP, with robust error handling and active maintenance.
Node-SSH is an extremely lightweight Promise wrapper for ssh2.
$ npm install node-ssh # If you're using npm
$ yarn add node-ssh # If you're using Yarn
const fs = require('fs')
const path = require('path')
const {NodeSSH} = require('node-ssh')
const ssh = new NodeSSH()
ssh.connect({
host: 'localhost',
username: 'steel',
privateKeyPath: '/home/steel/.ssh/id_rsa'
})
// or with inline privateKey
ssh.connect({
host: 'localhost',
username: 'steel',
privateKey: Buffer.from('...')
})
.then(function() {
// Local, Remote
ssh.putFile('/home/steel/Lab/localPath/fileName', '/home/steel/Lab/remotePath/fileName').then(function() {
console.log("The File thing is done")
}, function(error) {
console.log("Something's wrong")
console.log(error)
})
// Array<Shape('local' => string, 'remote' => string)>
ssh.putFiles([{ local: '/home/steel/Lab/localPath/fileName', remote: '/home/steel/Lab/remotePath/fileName' }]).then(function() {
console.log("The File thing is done")
}, function(error) {
console.log("Something's wrong")
console.log(error)
})
// Local, Remote
ssh.getFile('/home/steel/Lab/localPath', '/home/steel/Lab/remotePath').then(function(Contents) {
console.log("The File's contents were successfully downloaded")
}, function(error) {
console.log("Something's wrong")
console.log(error)
})
// Putting entire directories
const failed = []
const successful = []
ssh.putDirectory('/home/steel/Lab', '/home/steel/Lab', {
recursive: true,
concurrency: 10,
// ^ WARNING: Not all servers support high concurrency
// try a bunch of values and see what works on your server
validate: function(itemPath) {
const baseName = path.basename(itemPath)
return baseName.substr(0, 1) !== '.' && // do not allow dot files
baseName !== 'node_modules' // do not allow node_modules
},
tick: function(localPath, remotePath, error) {
if (error) {
failed.push(localPath)
} else {
successful.push(localPath)
}
}
}).then(function(status) {
console.log('the directory transfer was', status ? 'successful' : 'unsuccessful')
console.log('failed transfers', failed.join(', '))
console.log('successful transfers', successful.join(', '))
})
// Command
ssh.execCommand('hh_client --json', { cwd:'/var/www' }).then(function(result) {
console.log('STDOUT: ' + result.stdout)
console.log('STDERR: ' + result.stderr)
})
// Command with escaped params
ssh.exec('hh_client', ['--json'], { cwd: '/var/www', stream: 'stdout', options: { pty: true } }).then(function(result) {
console.log('STDOUT: ' + result)
})
// With streaming stdout/stderr callbacks
ssh.exec('hh_client', ['--json'], {
cwd: '/var/www',
onStdout(chunk) {
console.log('stdoutChunk', chunk.toString('utf8'))
},
onStderr(chunk) {
console.log('stderrChunk', chunk.toString('utf8'))
},
})
})
// API reference in Typescript typing format:
import SSH2, {
AcceptConnection,
Channel,
ClientChannel,
ConnectConfig,
ExecOptions,
Prompt,
PseudoTtyOptions,
RejectConnection,
SFTPWrapper,
ShellOptions,
TcpConnectionDetails,
TransferOptions,
UNIXConnectionDetails,
} from 'ssh2'
import stream from 'stream'
// ^ You do NOT need to import these package, these are here for reference of where the
// types are coming from.
export type Config = ConnectConfig & {
password?: string
privateKey?: string
privateKeyPath?: string
tryKeyboard?: boolean
onKeyboardInteractive?: (
name: string,
instructions: string,
lang: string,
prompts: Prompt[],
finish: (responses: string[]) => void,
) => void
}
export interface SSHExecCommandOptions {
cwd?: string
stdin?: string | stream.Readable
execOptions?: ExecOptions
encoding?: BufferEncoding
noTrim?: boolean
onChannel?: (clientChannel: ClientChannel) => void
onStdout?: (chunk: Buffer) => void
onStderr?: (chunk: Buffer) => void
}
export interface SSHExecCommandResponse {
stdout: string
stderr: string
code: number | null
signal: string | null
}
export interface SSHExecOptions extends SSHExecCommandOptions {
stream?: 'stdout' | 'stderr' | 'both'
}
export interface SSHPutFilesOptions {
sftp?: SFTPWrapper | null
concurrency?: number
transferOptions?: TransferOptions
}
export interface SSHGetPutDirectoryOptions extends SSHPutFilesOptions {
tick?: (localFile: string, remoteFile: string, error: Error | null) => void
validate?: (path: string) => boolean
recursive?: boolean
}
export type SSHMkdirMethod = 'sftp' | 'exec'
export type SSHForwardInListener = (
details: TcpConnectionDetails,
accept: AcceptConnection<ClientChannel>,
reject: RejectConnection,
) => void
export interface SSHForwardInDetails {
dispose(): Promise<void>
port: number
}
export type SSHForwardInStreamLocalListener = (
info: UNIXConnectionDetails,
accept: AcceptConnection,
reject: RejectConnection,
) => void
export interface SSHForwardInStreamLocalDetails {
dispose(): Promise<void>
}
export class SSHError extends Error {
code: string | null
constructor(message: string, code?: string | null)
}
export class NodeSSH {
connection: SSH2.Client | null
connect(config: Config): Promise<this>
isConnected(): boolean
requestShell(options?: PseudoTtyOptions | ShellOptions | false): Promise<ClientChannel>
withShell(
callback: (channel: ClientChannel) => Promise<void>,
options?: PseudoTtyOptions | ShellOptions | false,
): Promise<void>
requestSFTP(): Promise<SFTPWrapper>
withSFTP(callback: (sftp: SFTPWrapper) => Promise<void>): Promise<void>
execCommand(givenCommand: string, options?: SSHExecCommandOptions): Promise<SSHExecCommandResponse>
exec(
command: string,
parameters: string[],
options?: SSHExecOptions & {
stream?: 'stdout' | 'stderr'
},
): Promise<string>
exec(
command: string,
parameters: string[],
options?: SSHExecOptions & {
stream: 'both'
},
): Promise<SSHExecCommandResponse>
mkdir(path: string, method?: SSHMkdirMethod, givenSftp?: SFTPWrapper | null): Promise<void>
getFile(
localFile: string,
remoteFile: string,
givenSftp?: SFTPWrapper | null,
transferOptions?: TransferOptions | null,
): Promise<void>
putFile(
localFile: string,
remoteFile: string,
givenSftp?: SFTPWrapper | null,
transferOptions?: TransferOptions | null,
): Promise<void>
putFiles(
files: {
local: string
remote: string
}[],
{ concurrency, sftp: givenSftp, transferOptions }?: SSHPutFilesOptions,
): Promise<void>
putDirectory(
localDirectory: string,
remoteDirectory: string,
{ concurrency, sftp: givenSftp, transferOptions, recursive, tick, validate }?: SSHGetPutDirectoryOptions,
): Promise<boolean>
getDirectory(
localDirectory: string,
remoteDirectory: string,
{ concurrency, sftp: givenSftp, transferOptions, recursive, tick, validate }?: SSHGetPutDirectoryOptions,
): Promise<boolean>
forwardIn(remoteAddr: string, remotePort: number, onConnection?: SSHForwardInListener): Promise<SSHForwardInDetails>
forwardOut(srcIP: string, srcPort: number, dstIP: string, dstPort: number): Promise<Channel>
forwardInStreamLocal(
socketPath: string,
onConnection?: SSHForwardInStreamLocalListener,
): Promise<SSHForwardInStreamLocalDetails>
forwardOutStreamLocal(socketPath: string): Promise<Channel>
dispose(): void
}
node-ssh requires extra dependencies while working under Typescript. Please install them as shown below
yarn add --dev @types/ssh2
# OR
npm install --save-dev @types/ssh2
If you're still running into issues, try adding these to your tsconfig.json
{
"compilerOptions": {
"moduleResolution": "node",
"allowSyntheticDefaultImports": true
}
}
In some cases you have to enable keyboard-interactive user authentication.
Otherwise you will get an All configured authentication methods failed error.
const password = 'test'
ssh.connect({
host: 'localhost',
username: 'steel',
port: 22,
password,
tryKeyboard: true,
})
// Or if you want to add some custom keyboard-interactive logic:
ssh.connect({
host: 'localhost',
username: 'steel',
port: 22,
tryKeyboard: true,
onKeyboardInteractive(name, instructions, instructionsLang, prompts, finish) {
if (prompts.length > 0 && prompts[0].prompt.toLowerCase().includes('password')) {
finish([password])
}
}
})
For further information see: https://github.com/mscdex/ssh2/issues/604
This project is licensed under the terms of MIT license. See the LICENSE file for more info.