node-ssh vs ssh2
SSH Client Libraries for Node.js Automation
node-sshssh2Similar Packages:

SSH Client Libraries for Node.js Automation

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.

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,7581.12 MB877 months ago-

node-ssh vs ssh2: Choosing the Right SSH Client for Node.js

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.

🔌 Connection Setup: Promises vs Event Emitters

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-ssh requires key paths as strings; ssh2 expects actual Buffer or string content for private keys.

📁 File Transfers: Built-in SFTP vs Manual Channel Management

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.

⚙️ Advanced Features: What’s Missing at the Top?

node-ssh intentionally omits advanced SSH capabilities to keep its API small. You cannot do:

  • Local or remote port forwarding
  • Shell sessions with interactive input
  • Multiple concurrent exec channels
  • Custom authentication negotiation

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.

🧪 Error Handling and Debugging

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.

🔒 Connection Reuse and Resource Management

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.

🛠️ When to Drop Down to ssh2

Even if you start with node-ssh, you might hit limits:

  • Need to handle stderr separately from stdout? node-ssh merges them by default.
  • Want to stream command output in real time? node-ssh waits for completion.
  • Require keep-alive or custom timeouts? Only 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.

✅ Summary: Key Trade-offs

Concernnode-sshssh2
API StylePromise-based, high-levelEvent-driven, low-level
Learning CurveGentle—feels like child_processSteep—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 RiskLow (if needs stay simple)Higher (more code to manage)

💡 Final Recommendation

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

How to Choose: node-ssh vs ssh2

  • node-ssh:

    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.

  • ssh2:

    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.

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.