node-ssh and ssh2 are both npm packages that enable SSH connectivity from Node.js applications, allowing developers to execute remote commands, transfer files, and manage server interactions programmatically. ssh2 is a low-level, comprehensive implementation of the SSH2 protocol, offering fine-grained control over connections, authentication, and session management. node-ssh is a higher-level wrapper built on top of ssh2, designed to simplify common tasks like command execution and SFTP file operations with a more approachable API. Both target backend automation scenarios—such as deployment scripts, infrastructure tooling, or CI/CD integrations—but differ significantly in abstraction level and flexibility.
When automating server tasks from Node.js—like deploying code, syncing configs, or running diagnostics—you’ll likely reach for an SSH client library. The two main options are ssh2, a battle-tested low-level implementation of the SSH2 protocol, and node-ssh, a convenience layer built directly on top of ssh2. Despite sharing the same foundation, they serve very different developer needs. Let’s compare them head-to-head.
node-ssh uses promises and async/await for a clean, modern feel. You configure once and reuse the connection.
// node-ssh: Promise-based connection
import { NodeSSH } from 'node-ssh';
const ssh = new NodeSSH();
await ssh.connect({
host: 'example.com',
username: 'admin',
privateKey: '/path/to/key'
});
const result = await ssh.execCommand('ls -la');
console.log(result.stdout);
await ssh.dispose();
ssh2 exposes raw events and streams. You manage the connection lifecycle explicitly using event listeners.
// ssh2: Event-driven connection
import { Client } from 'ssh2';
const conn = new Client();
conn.on('ready', () => {
conn.exec('ls -la', (err, stream) => {
if (err) throw err;
let output = '';
stream.on('data', (chunk) => output += chunk);
stream.on('close', () => {
console.log(output);
conn.end();
});
});
}).connect({
host: 'example.com',
username: 'admin',
privateKey: require('fs').readFileSync('/path/to/key')
});
💡 Note:
node-sshrequires key paths as strings;ssh2expects actual Buffer or string content for private keys.
node-ssh includes high-level SFTP methods like putFile() and getFile() that handle path resolution and error wrapping.
// node-ssh: Simple file upload
await ssh.putFile('./local.txt', '/remote/path.txt');
// Download with automatic local directory creation
await ssh.getFile('./downloaded.txt', '/remote/file.txt');
ssh2 requires you to open an SFTP session manually and manage streams or buffers yourself.
// ssh2: Manual SFTP file upload
conn.on('ready', () => {
conn.sftp((err, sftp) => {
if (err) throw err;
sftp.fastPut('./local.txt', '/remote/path.txt', (err) => {
if (err) throw err;
console.log('Uploaded');
conn.end();
});
});
});
If you need to upload directories recursively or handle large files with progress tracking, node-ssh doesn’t support it out of the box—you’d have to drop down to ssh2’s SFTP interface anyway.
node-ssh intentionally omits advanced SSH capabilities to keep its API small. You cannot do:
ssh2 supports all standard SSH2 features:
// ssh2: Local port forwarding example
conn.on('ready', () => {
conn.forwardOut('127.0.0.1', 8080, 'remote-server.com', 80, (err, stream) => {
if (err) throw err;
// Now `stream` acts like a TCP socket to remote-server:80
stream.write('GET / HTTP/1.1\r\nHost: remote-server.com\r\n\r\n');
stream.on('data', console.log);
stream.on('close', () => conn.end());
});
});
This makes ssh2 the only viable choice for tools like tunnel managers, proxy jumpers, or custom CLI clients.
node-ssh wraps errors into standard JavaScript Error objects with descriptive messages, but loses some protocol-level context.
try {
await ssh.execCommand('invalid-command');
} catch (err) {
console.error(err.message); // "Command failed: ..."
}
ssh2 gives you precise error codes and events (error, end, close), which is critical for resilient automation:
conn.on('error', (err) => {
if (err.level === 'client-authentication') {
console.error('Auth failed:', err.description);
}
});
For production systems that must retry on timeout or distinguish auth failures from network issues, ssh2 provides the necessary granularity.
Both libraries allow reusing a single connection for multiple operations, but node-ssh abstracts this away—you just call execCommand() repeatedly. Under the hood, it manages a single underlying ssh2 client.
However, ssh2 lets you open multiple independent channels (e.g., one for SFTP, another for command execution) over the same encrypted connection—a more efficient use of resources in high-throughput scenarios.
// ssh2: Multiple channels on one connection
conn.on('ready', () => {
// Channel 1: Run command
conn.exec('uptime', (err, stream) => { /*...*/ });
// Channel 2: Start SFTP
conn.sftp((err, sftp) => { /*...*/ });
});
node-ssh serializes operations internally, so you can’t truly run commands in parallel—even if the SSH server supports it.
Even if you start with node-ssh, you might hit limits:
stderr separately from stdout? node-ssh merges them by default.node-ssh waits for completion.ssh2 exposes these options.In such cases, you can access the underlying ssh2 client via ssh.connection in node-ssh, but this breaks encapsulation and isn’t officially documented—making your code fragile.
| Concern | node-ssh | ssh2 |
|---|---|---|
| API Style | Promise-based, high-level | Event-driven, low-level |
| Learning Curve | Gentle—feels like child_process | Steep—requires SSH protocol knowledge |
| Common Tasks | ✅ Excellent for exec/SFTP | ✅ Possible, but verbose |
| Advanced Features | ❌ Not supported | ✅ Full SSH2 protocol coverage |
| Parallel Operations | ❌ Serialized | ✅ Multiple channels allowed |
| Error Detail | ❌ Abstracted | ✅ Precise error levels and codes |
| Maintenance Risk | Low (if needs stay simple) | Higher (more code to manage) |
Use node-ssh for quick scripts, deployment hooks, or internal tooling where you’re only running a few commands or transferring files. It reduces boilerplate and gets you shipping faster.
Use ssh2 when building production-grade infrastructure tools, security-critical systems, or anything requiring reliability, observability, or advanced SSH features. The extra verbosity pays off in control and resilience.
Remember: node-ssh is just a thin wrapper—it doesn’t add new capabilities beyond what ssh2 already provides. If your project might grow beyond basic automation, starting with ssh2 avoids a painful rewrite later.
Choose node-ssh if you need a simple, promise-based API for common SSH tasks like running shell commands or uploading/downloading files via SFTP. It’s ideal for scripting, DevOps automation, or internal tools where rapid development and readability matter more than granular protocol control. Avoid it if you require advanced SSH features like port forwarding, multiple channels, or custom authentication flows.
Choose ssh2 if you need full control over the SSH protocol—such as managing multiple interactive sessions, implementing custom authentication methods, or handling complex connection lifecycles. It’s better suited for building robust infrastructure tools, security-sensitive applications, or when you must handle edge cases like connection retries, keep-alive, or tunneling. Be prepared to write more boilerplate and manage async event flows manually.
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.