The patch rollout was one line: for h in $(cat hosts.txt); do ssh $h 'sudo apt-get install -y openssl'; done. It printed a wall of apt output, ended, and the ticket was closed with "all 22 hosts patched." Three of those hosts had been rebuilt the month before and never got the deploy key. Their ssh calls printed Permission denied (publickey) somewhere in the middle of the wall, exited 255, and the loop moved on because a for loop does not care what its body returned. Nobody read 22 hosts' worth of apt output looking for one line that was not apt. The three unpatched boxes were found by the scanner six weeks later, which is a better outcome than the alternative.
Running a command on a remote host is one ssh invocation. Running it on twenty hosts and knowing what happened on each one is a small set of flags and one habit: capture the exit code, every time.
Same fail-loud pattern as talking to an API
An ssh call and a curl call fail the same way: the transport succeeds, the thing on the other end reports a problem, and the script that ignores the return value carries on. The guide on shell scripts that talk to APIs is the same discipline — capture the status, distinguish "could not reach it" from "it said no", stop on either. Everything below assumes key-based login is already working; if it is not, start with the SSH key setup script.
The Script
What does it print when a host is unreachable?
Honest disclosure: the laptop this page was written on does not run sshd, so every output below that involves connecting is the failure path, captured live. It is also the path that matters most, because it is the one the apt loop threw away.
Two hosts, two refused connections, a summary line, exit 1. Drop the same two names into hosts.txt with a comment and a blank line, run it with only the command, and the file is parsed the same way. With a reachable host, the ✓ line is followed by the command's output indented four spaces, and a host where the command itself failed shows exit 1 (or whatever the command returned) instead of the ssh error — the two classes are printed differently on purpose.
The ${1:?usage ...} guard fires when no command is given at all:
How do I run a single command over ssh?
ssh host 'command'. ssh opens the connection, hands the string to the remote user's login shell, streams the output back, and exits with the command's status. The whole difficulty is in the quotes, and it comes down to one question: which shell should expand the $? Watch what the local shell passes to ssh in each case:
Double quotes expanded $HOME here, so the remote host would print the local home directory — or, in the apt loop's case, a variable that was set on your laptop and empty on the server. Single quotes send $HOME across intact and the remote shell expands it. The rule: single-quote the remote command by default; switch to double quotes only for the specific values you want substituted from the local side, and read the result of echo ssh ... before trusting it. The script passes "$REMOTE_CMD" as one argument after -- for the same reason — the string you typed reaches the remote shell as one string.
How do I run several commands at once?
For a couple of commands, && inside the single quotes: ssh host 'cd /srv/app && git pull && systemctl restart app'. The && stops the chain at the first failure, and the exit code you get back is the failing command's. For anything that would not fit on one line, hand a heredoc to a remote shell:
bash -s reads the script from stdin; the heredoc is that stdin. The quoted <<'EOF' is the single-quote rule again: with it, nothing inside the block is expanded locally. set -euo pipefail on the first remote line means the block stops at its first failure and that failure becomes ssh's exit code. The quoting rule can be demonstrated without a remote host, because it is the local shell doing the work:
The first block was rewritten before bash -s saw it. The second reached bash -s untouched and was expanded there — swap bash -s for ssh host bash -s and "there" is the remote machine. For a block you will run more than once, save it as a file and pipe it: ssh host bash -s < deploy.sh.
When do I need -t?
When the remote command needs a terminal: sudo asking for a password, top, vim, anything interactive. -t allocates a pseudo-terminal on the remote side. The catch is that it needs one on the local side too, and a script has none:
That first line is ssh telling you -t did nothing. In a cron job or a CI runner, a sudo that needs a password will fail even with -t — which is correct, because the alternative is a password in a script. Unattended sudo over ssh means a NOPASSWD sudoers rule scoped to the exact command, the same way the service watchdog handles systemctl start under cron. Leave -t out of scripts; it also changes line endings to \r\n in captured output.
Why did my while-read loop stop after the first host?
Because ssh reads stdin, and inside while read host; do ...; done < hosts.txt, stdin is hosts.txt. The first ssh call connects, then drains every remaining line of the file as input for the remote command. The loop wakes up to an empty file. Here is the same failure with cat standing in for ssh:
ssh -n is the built-in </dev/null. The script iterates an array rather than a file stream, so it is not exposed — and it passes -n anyway, because someone will eventually turn that array into a pipeline. The file-reading snippet covers the rest of the while read traps.
How do I keep it from hanging, and what does exit 255 mean?
-o BatchMode=yes tells ssh to never ask a question: no password prompt, no "are you sure you want to continue connecting" for an unknown host key. Either condition becomes an immediate exit 255 instead of a loop that sits at a prompt until someone finds it in the morning. -o ConnectTimeout=5 puts a bound on a host that is down or firewalled; without it the default TCP timeout can be over two minutes per host. Twenty hosts, three unreachable — that is six minutes of nothing versus fifteen seconds.
255 is the exit code ssh reserves for itself: DNS failure, connection refused, timeout, host key mismatch, rejected key. Every other code came from the remote command. The script branches on that number so a network problem and a failing command look different in the report, because they are fixed in different places. A retry helps with the first class and hurts with the second; the retry with backoff pattern wraps the ssh call when the transient case is worth handling.
Frequently Asked Questions
How do I run a command on a remote server with ssh without logging in interactively?
Append the command: ssh user@host 'df -h /'. ssh connects, runs it through the remote login shell, prints the output, and returns its exit code — no prompt. Single-quote the command so anything with a $ is expanded remotely; double quotes expand it locally first. For unattended use, key-based authentication must already work, or ssh stops to ask for a password.
How do I run multiple commands over ssh?
Join two or three with && inside one quoted string — ssh host 'cd /srv/app && git pull && systemctl restart app' — and the chain stops at the first failure. For longer blocks, send a heredoc to a remote shell: ssh host bash -s <<'EOF', the commands, then EOF; the quoted EOF keeps your local shell from expanding $variables. For anything reusable, ssh host bash -s < deploy.sh. All three return the exit code of the last command that ran.
Why does my ssh loop only run for the first host?
Because ssh reads standard input, and inside a while read host loop, standard input is your host list. The first call drains the remaining lines as input to the remote command and the loop exits after one iteration. Pass -n to ssh, or add < /dev/null. A for host in "${HOSTS[@]}" loop over an array does not have the problem, but -n costs nothing and survives a later rewrite.
What does ssh exit code 255 mean?
255 means ssh failed to connect or authenticate and never ran your command: DNS, connection refused, ConnectTimeout expired, host key mismatch, or a key rejected under BatchMode=yes. Every other code is passed through from the remote command — 0 success, 1 generic failure, 127 command not found remotely. Treat 255 as a different class of failure, because the fix is on the network or in ~/.ssh, not in the command.
How do I run sudo over ssh?
With a NOPASSWD sudoers rule for that command, no extra flags: ssh host 'sudo systemctl restart nginx'. If sudo needs a password it needs a terminal, so add -t for interactive use. Inside a script with no terminal of its own, -t prints "Pseudo-terminal will not be allocated because stdin is not a terminal" and the prompt still fails — the right outcome. Unattended sudo over ssh should use a NOPASSWD rule scoped to the exact command, never a password on stdin.
Part of the bash snippets collection