node-ssh vs ssh2 vs ssh2-promise vs ssh2-sftp-client
SSH Client Libraries for Node.js Automation
node-sshssh2ssh2-promisessh2-sftp-client

SSH Client Libraries for Node.js Automation

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.

Npm Package Weekly Downloads Trend

3 Years

Github Stars Ranking

Stat Detail

Package
Downloads
Stars
Size
Issues
Publish
License
node-ssh099678.3 kB58a year agoMIT
ssh205,7591.12 MB856 months ago-
ssh2-promise0151169 kB20-MIT
ssh2-sftp-client0913244 kB28 months agoApache-2.0

SSH Client Libraries in Node.js: A Practical Comparison

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.

🔌 Core Architecture: Low-Level vs High-Level Abstractions

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();

🛠️ Common Tasks: How Each Package Handles Them

Running Shell Commands

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.

File Transfers (SFTP)

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.

Connection Management & Cleanup

  • 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.

⚠️ Maintenance and Deprecation Status

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.

🧪 Real-World Scenarios

Scenario 1: You Need to Run Commands and Occasionally Transfer Files

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();

Scenario 2: You Only Need SFTP (No Shell Commands)

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();

Scenario 3: You Need Fine-Grained Control Over SSH Protocol Details

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({ /* ... */ });

Scenario 4: You’re Starting a New Project and Considered ssh2-promise

Do not use ssh2-promise. It’s deprecated, unmaintained, and offers no advantage over using ssh2 directly with modern async/await patterns.

📊 Summary Table

PackageShell CommandsSFTP SupportPromise-BasedAuto CleanupMaintenance Status
ssh2✅ Full✅ Manual❌ (events)✅ Active
node-ssh✅ High-level✅ Basic✅ Active
ssh2-promise✅ Manual✅ (partial)Deprecated
ssh2-sftp-client✅ Rich✅ Active

💡 Final Recommendation

  • For most developers: Use node-ssh if you need both command execution and occasional file transfers. It strikes the best balance between simplicity and capability.
  • For SFTP-only workflows: Choose ssh2-sftp-client — it’s reliable, focused, and well-maintained.
  • For advanced SSH needs: Drop down to ssh2 when you need protocol-level control.
  • Avoid 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.

How to Choose: node-ssh vs ssh2 vs ssh2-promise vs ssh2-sftp-client

  • node-ssh:

    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.

  • ssh2:

    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.

  • ssh2-promise:

    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).

  • ssh2-sftp-client:

    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.

README for node-ssh

Node-SSH - SSH2 with Promises

Node-SSH is an extremely lightweight Promise wrapper for ssh2.

Installation

$ npm install node-ssh # If you're using npm
$ yarn add node-ssh # If you're using Yarn

Example

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

// 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
}

Typescript support

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
  }
}

Keyboard-interactive user authentication

In some cases you have to enable keyboard-interactive user authentication. Otherwise you will get an All configured authentication methods failed error.

Example:

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

License

This project is licensed under the terms of MIT license. See the LICENSE file for more info.