Skip to content
shellmap

Run a remote command non-interactively

Execute a single command on a remote host over SSH without opening an interactive shell — capture its stdout/stderr/exit-code on the local side. The canonical "ssh user@host 'systemctl status nginx'" gesture used in deploy scripts, CI, and ad-hoc remote inspection.

How to run a remote command non-interactively in each shell

Bashunix
ssh user@host "systemctl is-active nginx"

Canonical form. Single quotes prevent local shell from expanding `$var`, `*`, etc. — useful when the variable should resolve REMOTELY: `ssh host 'echo $HOSTNAME'` prints the REMOTE hostname; `ssh host "echo $HOSTNAME"` (double quotes) prints your LOCAL hostname because bash expands `$HOSTNAME` before ssh sees it. For "no tty, no prompts, fail-fast": `ssh -T -o BatchMode=yes -o ConnectTimeout=5 user@host "cmd"` — `-T` disables pseudo-terminal allocation (cmd runs straight, no `\r\n` translation), `BatchMode=yes` disables password / passphrase prompts (key-only, fails immediately if no key), `ConnectTimeout=5` bounds the TCP handshake. Always use these in CI.

Zshunix
ssh user@host "systemctl is-active nginx"
Fishunix
ssh user@host "systemctl is-active nginx"

Same ssh binary — fish does not parse the remote command differently. CAVEAT: fish's `$()` is NOT command substitution — fish uses `(cmd)` for that. If you embed a local subcommand in the remote command string, the brace form (`(date +%Y)`) belongs OUTSIDE the quoted remote command: `ssh host "echo $(date +%Y)"` would fail in fish (no `$()` form). Idiomatic fish: `set today (date +%Y); ssh host "echo $today"`.

PowerShellwindows
ssh user@host "systemctl is-active nginx"

pwsh 7+ on Windows 10 1809+ ships OpenSSH — same ssh binary. For a more PowerShell-native flow: `Invoke-Command -ComputerName host -ScriptBlock { systemctl is-active nginx } -Credential (Get-Credential)` uses WinRM under the hood (NOT ssh — requires WinRM enabled on the remote, which is a Windows-on-Windows feature) and returns RICH PSObjects, not text. For SSH-over-PowerShell-Remoting: `Enter-PSSession -HostName host -UserName user -KeyFilePath ~/.ssh/id_ed25519` opens a PSRemoting session over ssh (pwsh 6+ feature, requires pwsh on the remote and an `SSHServerConfig` block in remote `sshd_config`). For one-off PSObject output: `Invoke-Command -HostName host -UserName user -ScriptBlock { Get-Service nginx | Where Status -eq Running }`.

cmd.exewindows
ssh user@host "systemctl is-active nginx"

Same OpenSSH binary. Legacy alternative: `plink -batch -ssh user@host "systemctl is-active nginx"` (PuTTY suite). `-batch` disables prompts (key authentication or fail — cmd has no `-o BatchMode` flag, `-batch` is the plink-specific form). For .ppk key auth: `plink -i C:\keys\id.ppk -batch -ssh user@host "cmd"`. Exit code: cmd captures it in `%ERRORLEVEL%`. CAVEAT: cmd's quoting is even more fragile than bash — embedded `"` quotes need to be escaped as `\"` AND the surrounding double-quotes are required because cmd does not have single-quote-as-literal: `ssh user@host "echo \"hello world\""` is the only portable form when the remote command itself contains spaces and quotes.

Equivalents listed for Bash, Zsh, Fish, PowerShell, cmd.exe.

Gotchas & notes

  • **Exit-code propagation: remote rc becomes local rc.** When you run `ssh host "cmd"`, the LOCAL `ssh` process exits with the REMOTE command's exit code (after a successful connection). So `ssh host "false"; echo $?` prints `1` — exactly as if `false` had run locally. CAVEAT: if the SSH connection itself fails (DNS, TCP, auth), ssh exits with `255` — distinguishable from any normal command exit code (which is `0–254`). In CI scripts: `if ! ssh -o BatchMode=yes host "cmd"; then echo "remote failed: rc=$?"; fi`. Tests for "remote rc 0 = success" vs "ssh-itself rc 255 = connection problem" let you handle network blips differently from logic failures. Special case for pipes: `ssh host "false | true"; echo $?` prints `0` because `true` is the last in the pipe — set `pipefail` on the REMOTE: `ssh host "set -o pipefail; false | true"; echo $?` → `1`.
  • **Quoting layers: local shell parses ONCE, then remote shell parses AGAIN.** The command string passes through TWO shells. Local single-quotes (`'`) prevent local expansion; double-quotes (`"`) allow local expansion, then the result is re-parsed by the remote shell. To pass a literal `$VAR` to the remote: `ssh host 'echo $VAR'` (single quotes — local sees `echo $VAR` literally). To pass a local variable VALUE to remote: `ssh host "echo $VAR"` (double quotes — local expands `$VAR` to its value, remote sees the value as a literal). For complex multi-line scripts, avoid escape-hell by using a here-doc: `ssh host bash <<'EOF'\nfor f in *.log; do gzip "$f"; done\nEOF`. The quoted `'EOF'` (single-quoted heredoc terminator) means NO local expansion — the script runs verbatim on the remote, including `$f`, `*.log` etc. resolving REMOTELY.
  • **`ssh -T` vs default — tty allocation matters for stdout consumers.** Without `-T` or `-t`, ssh decides automatically: a remote command argument gets NO tty; `ssh host` with no command gets a tty. With a tty, the remote sees stdin as a tty, so programs like `sudo` (which checks `isatty(STDIN)` to decide whether to prompt) BEHAVE DIFFERENTLY: `ssh host "sudo -n cmd"` (the `-n` for non-interactive sudo) errors if a tty is allocated AND no NOPASSWD rule exists. `ssh -T` forces no-tty — clean stdout for piping, no `\r\n` line-ending mangling, no terminal escape sequences. `ssh -t` (or `-tt`) force-allocates a tty — useful when running `vim` / `htop` / anything ncurses; less useful for "capture this output" workflows. CI rule: `-T` for "capture output", `-tt` only when launching an interactive remote tool through automation.
  • **SSH config (~/.ssh/config) is the right place to put host shortcuts.** Per-host settings like `User`, `Port`, `IdentityFile`, `ProxyJump`, `RemoteCommand`, `RequestTTY` in `~/.ssh/config` apply automatically every time you ssh to that host. Example block: `Host prod`/`HostName prod-cluster-01.internal`/`User deploy`/`Port 2222`/`IdentityFile ~/.ssh/deploy_ed25519`/`ProxyJump bastion`/`ServerAliveInterval 30`/`ServerAliveKeepAlive yes` — then `ssh prod "uptime"` just works. For shared CI: commit a `.ssh/config` template under version control + symlink in via Ansible. CAVEAT: `~/.ssh/config` permissions MUST be `0600` (`chmod 600 ~/.ssh/config`) or ssh refuses to read it (treats it as insecure). The directory itself must be `0700`. Same constraint on Windows pwsh — set ACLs via `icacls $env:USERPROFILE\.ssh\config /inheritance:r /grant:r "$env:USERNAME:F"`.
  • **Long-running remote commands and the dropped-connection problem.** If the local ssh connection dies (laptop sleep, WiFi blip, hotel network), the remote command MAY get a `SIGHUP` and die. Solutions in order of preference: (1) `ssh host "nohup long-running-job &"` — detaches from the tty, survives SSH disconnect, but stdout is lost (redirect to a file: `nohup cmd > /tmp/cmd.log 2>&1 &`). (2) `ssh host "tmux new -d -s job 'long-running-job'"` — runs inside tmux; reattach later with `ssh -t host tmux attach -t job`. (3) `systemd-run --user --unit=myjob --remain-after-exit long-running-job` (systemd `--user` instances) — survives logout, viewable with `systemctl --user status myjob`, output in `journalctl --user -u myjob`. For deploy scripts: prefer option 3 — clean state model, no stale tmux sessions to garbage-collect. CI bonus: `ssh host "long-running-job"` with `ServerAliveInterval 30`/`ServerAliveKeepAlive yes` in ssh config keeps the connection alive across lossy networks for many minutes.

Related tasks