| Tested on | RHEL 10.2 (Coughlan) |
|---|---|
| Package | bash-5.2.26-6.el10.x86_64openssh-clients-9.9p1-25.el10_2.x86_64 |
| Applies to | Linux distributions with Bash installed |
| Privilege | Normal user with SSH key access to target hosts |
| Scope | Bash SSH scripts that run commands on multiple servers: key-based auth, BatchMode, ConnectTimeout, hosts-file loops, the stdin-consumption bug and ssh -n, host-key policy, ~/.ssh/config, remote exit codes, quoting, sequential and parallel multi-host runs, ControlMaster, bash -s, and non-interactive sudo. Does not cover password-based login (pass an SSH password in a script) or full Ansible playbooks. |
| Related guides | SSH command Shell scripting course hub |
A Bash loop can run the same SSH command across a list of Linux servers. For unattended runs, use key-based authentication, BatchMode=yes, a connection timeout, and per-host exit-status handling.
The examples use localhost and 127.0.0.1 so the commands are reproducible on one SSH server. Replace them with your own hostnames or SSH aliases.
Quick Answer: run SSH commands on every server
Save your server list in hosts.txt (one hostname per line), then loop with ssh -n so SSH does not read from the same stdin as the hosts loop:
while IFS= read -r host || [[ -n $host ]]; do
[[ -z $host || $host == \#* ]] && continue
echo "=== $host ==="
if ssh -n -o BatchMode=yes -o ConnectTimeout=5 "$host" 'uptime -p'; then
echo "$host: OK"
else
rc=$?
echo "$host: FAILED (exit $rc)" >&2
fi
done < hosts.txtAn exit status other than 255 usually comes from the remote command; 255 normally indicates an SSH-level failure, although a remote command can also explicitly return 255.
Sample output:
=== localhost ===
up 8 hours, 2 minutes
localhost: OK
=== 127.0.0.1 ===
up 8 hours, 2 minutes
127.0.0.1: OK-n keeps SSH from reading the same stdin as the hosts loop, BatchMode=yes disables interactive authentication prompts, and ConnectTimeout limits how long each connection attempt can delay the loop. This assumes key-based authentication works and the target host keys are already trusted; the setup sections below cover both requirements.
Prepare SSH for Non-Interactive Automation
Unattended scripts should fail instead of waiting for password or passphrase input. Set up key-based login first, then force non-interactive behavior in every ssh call.
On the machine that runs your script:
- Generate a key pair if you do not already have one:
ssh-keygen -t ed25519 - Copy the public key to each target with
ssh-copy-id user@host(one-time, interactive) - Confirm key login works before you wrap commands in a loop
Do not embed SSH passwords in scripts or environment variables. For unattended SSH automation, prefer key-based authentication with BatchMode=yes (below).
If the private key has a passphrase, load it into ssh-agent before running unattended commands. BatchMode=yes disables passphrase prompts, so a protected key that is not already available through an agent may fail authentication.
Use BatchMode=yes
BatchMode=yes disables interactive authentication prompts, such as password and passphrase prompts, so SSH fails instead of waiting for input. Host-key handling is controlled separately by StrictHostKeyChecking. Configure host keys beforehand or use an appropriate StrictHostKeyChecking policy as described later.
For example, an authentication failure returns immediately instead of opening an interactive prompt:
ssh -o BatchMode=yes -o PreferredAuthentications=password -o PubkeyAuthentication=no nonexistent@localhost 'hostname'
rc=$?
printf 'ssh exit: %d\n' "$rc"Sample output:
nonexistent@localhost: Permission denied (publickey,password).
ssh exit: 255SSH uses 255 for client and session errors such as auth failure, network failure, or host-key rejection. It is not the remote command exit code when the session never started.
Run a Remote Command
Put the command after the SSH destination:
ssh host 'hostname'SSH runs the command through the remote account's shell and returns its output locally. Test one host before adding the command to a loop:
ssh -o BatchMode=yes localhost 'hostname'Sample output:
vm1.lab.exampleThe remote hostname printed on stdout; SSH itself exited 0 because authentication and transport succeeded.
Run Multiple Commands over SSH
Chain several remote commands in one quoted string. A semicolon runs every command regardless of the previous exit status:
ssh -n -o BatchMode=yes localhost 'hostname; uptime -p; df -h / | tail -1'Sample output:
vm1.lab.example
up 8 hours, 2 minutes
/dev/mapper/rhel-root 25G 16G 9.3G 64% /Use && when a later command should run only if the earlier one succeeded:
ssh -n -o BatchMode=yes localhost 'false && echo never'
rc=$?
printf 'ssh exit: %d\n' "$rc"Sample output:
ssh exit: 1Because false returned 1, && skipped echo never. The remote shell exited with status 1, which SSH returned to the local shell. For longer logic, pipe a local script with bash -s (covered later) instead of building a long quoted string.
Read Servers from a Hosts File
Keep one hostname or alias per line. This simple parser skips empty lines and comments whose first character is #. It does not trim leading or trailing whitespace.
Create a lab hosts file with a comment and two targets:
printf '# lab hosts\nlocalhost\n127.0.0.1\n' > hosts.txtConfirm the file content before you loop:
cat hosts.txtSample output:
# lab hosts
localhost
127.0.0.1Replace them with the hostnames or SSH aliases you want to manage.
Run SSH Commands on Every Server
Add per-host status tracking to the loop. Store host order in an array so the summary matches whatever hosts.txt contains:
declare -A results=()
hosts=()
while IFS= read -r host || [[ -n $host ]]; do
[[ -z $host || $host == \#* ]] && continue
hosts+=("$host")
if ssh -n -o BatchMode=yes -o ConnectTimeout=3 "$host" 'uptime -p'; then
results[$host]=ok
else
results[$host]=fail
fi
done < hosts.txt
printf '%-15s %s\n' HOST STATUS
for host in "${hosts[@]}"; do
printf '%-15s %s\n' "$host" "${results[$host]}"
doneSample output:
up 8 hours, 2 minutes
up 8 hours, 2 minutes
HOST STATUS
localhost ok
127.0.0.1 okuptime -p printed for each live host. The array preserves host order so the script can print one final status per host.
Why an SSH Loop Stops After the First Host
A common failure occurs when ssh runs inside a while read loop. When you redirect a hosts file into while read, the loop and ssh share the same stdin file descriptor. ssh inherits the loop's stdin and may consume data that read expects to receive. As a result, later hostnames can disappear from the loop — often making it stop after the first SSH call.
Without -n, the loop may stop after the first SSH call:
while IFS= read -r host || [[ -n $host ]]; do
[[ -z $host || $host == \#* ]] && continue
echo "SSH to: $host"
ssh -o BatchMode=yes -o ConnectTimeout=3 "$host" 'hostname'
done < hosts.txtSample output:
SSH to: localhost
vm1.lab.exampleThe loop printed one SSH to: line even though hosts.txt has two entries. SSH consumed 127.0.0.1 from stdin before read could see it.
Fix the Loop with ssh -n
ssh -n redirects SSH's stdin from /dev/null, so the loop keeps reading the hosts file. Use it for remote commands that do not need stdin (typical one-liners like uptime, systemctl status, or df).
Run the same loop with -n:
while IFS= read -r host || [[ -n $host ]]; do
[[ -z $host || $host == \#* ]] && continue
echo "SSH to: $host"
ssh -n -o BatchMode=yes -o ConnectTimeout=3 "$host" 'hostname'
done < hosts.txtSample output:
SSH to: localhost
vm1.lab.example
SSH to: 127.0.0.1
vm1.lab.exampleDo not use -n when you intentionally send a script or other data through SSH stdin, as in the bash -s example below.
Instead of ssh -n, redirect SSH's stdin explicitly on each iteration — see Bash redirection for how stdin flows in loops:
while IFS= read -r host || [[ -n $host ]]; do
[[ -z $host || $host == \#* ]] && continue
ssh -o BatchMode=yes -o ConnectTimeout=3 "$host" 'hostname' < /dev/null
done < hosts.txt-n is simply the shorter equivalent for commands that do not need stdin.
Set SSH Timeouts
Without a timeout, SSH can sit for a long time on a dead host while your script stalls. ConnectTimeout limits how long SSH waits while establishing the connection. It does not limit how long the remote command may run after the session is established.
To see the timeout behavior, use a test address where the connection attempt receives no response. The exact error depends on your network. With a target that silently drops connection attempts, ConnectTimeout=2 returns after roughly two seconds:
time ssh -o BatchMode=yes -o ConnectTimeout=2 192.0.2.99 'hostname'Example output from the RHEL test host:
ssh: connect to host 192.0.2.99 port 22: Connection timed out
real 0m2.091s
user 0m0.010s
sys 0m0.014sSome networks may return Network is unreachable immediately instead. Pair ConnectTimeout with BatchMode=yes on every automated call.
Connection Timeout vs Command Timeout
ConnectTimeout only covers connection setup. After the session is up, two separate problems remain.
ServerAliveInterval makes the SSH client periodically send a keepalive request when no data has been received from the server over the SSH connection. ServerAliveCountMax controls how many unanswered keepalives are allowed before the client disconnects:
ServerAliveInterval 30
ServerAliveCountMax 3Add these options to ~/.ssh/config or pass them with -o. These options detect a broken or unresponsive SSH connection; they do not impose a runtime limit on a healthy remote command.
A remote command that runs too long needs its own limit on the server. Run timeout inside the remote command string:
ssh -n -o BatchMode=yes -o ConnectTimeout=5 \
localhost 'timeout 60s long-running-command'Use timeout on the remote host when you need to limit the remote command itself. Killing the local ssh client only closes the client side of the session; do not assume that this reliably terminates every remote process it started.
Handle SSH and Remote Command Exit Codes
If the remote command runs, ssh returns that command's exit status. SSH itself uses 255 when an SSH-level error occurs.
A remote exit status is returned by ssh:
ssh -n -o BatchMode=yes localhost 'exit 42'
rc=$?
printf 'ssh exit: %d\n' "$rc"Sample output:
ssh exit: 42Compare that with an SSH-level failure (exact message varies by network):
ssh -n -o BatchMode=yes -o ConnectTimeout=2 192.0.2.99 'exit 0'
rc=$?
printf 'ssh exit: %d\n' "$rc"Example output from the test host:
ssh: connect to host 192.0.2.99 port 22: Connection timed out
ssh exit: 255A remote command can itself return 255, so the value alone cannot always distinguish an SSH failure from a remote exit 255.
In scripts, test $? or if ssh ... after each call and record per-host results. See Bash exit codes for how $? interacts with set -e and pipelines — SSH in a loop often needs explicit status capture, not bare set -e.
Quote Local and Remote Variables Correctly
The shell that parses the ssh command line is local. The shell that runs the remote command is on the target. Single quotes around the remote command stop local expansion; double quotes allow it.
Double quotes allow local expansion before SSH runs:
ssh -n localhost "echo local HOME=$HOME"Sample output:
local HOME=/home/adminSingle quotes preserve $HOME for the remote shell:
ssh -n localhost 'echo "remote HOME=$HOME"'Sample output:
remote HOME=/home/adminUse "$host" locally for the connection target. Avoid building remote shell commands by interpolating arbitrary or untrusted values. For multi-line commands or structured inputs, send a script via stdin as shown later.
Configure Host-Key Checking Safely
SSH verifies the server's host key against known_hosts. On the first connection, SSH must decide whether to trust the server's host key. Later connections can detect unexpected changes to that key.
| Value | Behavior |
|---|---|
yes |
Never automatically add new host keys; reject hosts whose key is unknown or has changed |
ask |
Ask before trusting a new host; reject changed keys |
accept-new |
Automatically trust new hosts; reject changed keys |
no / off |
Automatically add new host keys and permit some connections with changed keys subject to restrictions; avoid as a general automation default |
For stricter automation, populate the expected host keys before running the job and verify their fingerprints through a trusted source rather than learning them on first connection. Collecting a key with ssh-keyscan stores it in known_hosts, but that alone does not prove the key is trustworthy.
To test accept-new with an isolated known_hosts file:
ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/lab_known_hosts -o BatchMode=yes 127.0.0.1 'hostname'Sample output:
Warning: Permanently added '127.0.0.1' (ED25519) to the list of known hosts.
vm1.lab.exampleaccept-new is convenient when your automation must connect to new hosts without prompting. It still rejects a host if its previously recorded key changes. For systems where the first connection must also be authenticated, pre-populate known_hosts with keys obtained through a trusted channel.
When a key changes, SSH stops with a clear error. Simulate a changed key with an isolated known-hosts file that holds the wrong key:
printf 'localhost ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAILVebo2sRI15QUjvjavWtkwcxPW383/ZASpAwmEaFqM8\n' > /tmp/wrong_kh
ssh -o UserKnownHostsFile=/tmp/wrong_kh -o BatchMode=yes -o StrictHostKeyChecking=yes localhost 'hostname'Sample output (trimmed):
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@ WARNING: REMOTE HOST IDENTIFICATION HAS CHANGED! @
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
...
Host key verification failed.SSH rejects the connection instead of accepting the changed key automatically.
Put Repeated SSH Options in ~/.ssh/config
Repeated flags belong in SSH client config, not in every script line. Create a private control-socket directory before enabling connection multiplexing:
mkdir -p ~/.ssh/control
chmod 700 ~/.ssh/controlA Host entry is an alias; HostName defines the actual destination. This example defines lab-local as an alias for localhost:
Host lab-local
HostName localhost
User admin
IdentityFile ~/.ssh/id_ed25519
BatchMode yes
ConnectTimeout 10
ControlMaster auto
ControlPersist 30s
ControlPath ~/.ssh/control/%CThen ssh -n lab-local 'uptime' picks up those defaults without repeating flags on the command line. Host-key policy (StrictHostKeyChecking, UserKnownHostsFile) is not shown here — use your normal policy or pre-populated known_hosts for managed hosts. Use Host * sparingly; global StrictHostKeyChecking no affects every connection from that account.
ControlPath ~/.ssh/control/%C keeps the socket in a directory not writable by other users and avoids long socket paths from lengthy hostnames.
Run SSH Commands in Parallel
Sequential execution waits for each SSH session to finish before starting the next. For many hosts, bounded parallel execution reduces total runtime without starting every connection at once. Reuse the worker-pool pattern from run Bash commands in parallel: cap concurrent jobs with wait -n -p (Bash 5.1+).
This example allows at most four parallel SSH sessions:
MAX_JOBS=4
declare -A pid_to_host=()
declare -A host_rc=()
hosts=()
reap_one() {
local finished_pid rc h
finished_pid=
if wait -n -p finished_pid; then rc=0; else rc=$?; fi
[[ -n ${finished_pid:-} ]] || {
echo "internal error: wait returned no child PID" >&2
exit 1
}
h=${pid_to_host[$finished_pid]}
host_rc[$h]=$rc
unset 'pid_to_host[$finished_pid]'
}
while IFS= read -r host || [[ -n $host ]]; do
[[ -z $host || $host == \#* ]] && continue
hosts+=("$host")
while ((${#pid_to_host[@]} >= MAX_JOBS)); do reap_one; done
ssh -n -o BatchMode=yes -o ConnectTimeout=3 "$host" 'hostname' >/dev/null &
pid_to_host[$!]=$host
done < hosts.txt
while ((${#pid_to_host[@]} > 0)); do reap_one; done
for host in "${hosts[@]}"; do
echo "$host ssh_exit=${host_rc[$host]}"
doneSample output:
localhost ssh_exit=0
127.0.0.1 ssh_exit=0Each background SSH process is mapped to its host. reap_one stores the completed SSH command's status in host_rc instead of returning that SSH status to the caller. This allows one failed host to be recorded without treating it as a worker-pool failure — do not change reap_one to return "$rc" if you enable set -e. The explicit exit 1 is only for the unexpected case where wait -n -p does not provide a completed PID. This example assumes each hostname occurs only once because results are keyed by hostname.
Adjust MAX_JOBS if servers limit concurrent connections. For much larger host lists, pssh, GNU Parallel, or Ansible is usually easier to maintain than a custom Bash worker pool.
Reuse SSH Connections with ControlMaster
Opening a new SSH connection for every short command repeats connection setup and authentication. With ControlMaster auto, SSH can keep the first connection as a master. Later sessions using the same ControlPath reuse it instead of opening a new connection.
Run two commands through the lab-local alias from the config block above:
ssh -n lab-local 'echo first'Sample output:
firstA second call while the master socket from ControlPersist is still alive reuses the connection:
ssh -n lab-local 'echo second via mux'Sample output:
second via muxControlPersist=30s keeps the master open briefly after the last session closes — useful in scripts that fan out many quick checks.
Send a Local Bash Script over SSH
When the remote command is more than one line, pipe a local script to bash -s on the target. bash -s reads the script from standard input. In bash -s -- 90, -- ends Bash option processing and 90 becomes $1 in the remote script.
Create a small disk-check script locally. This example assumes GNU df, as used on the RHEL test host:
cat > check_disk.sh << 'EOF'
#!/usr/bin/env bash
set -euo pipefail
threshold="${1:?usage: threshold_pct}"
used=$(df / --output=pcent | tail -1 | tr -dc '0-9')
echo "root used ${used}% (threshold ${threshold}%)"
(( used < threshold ))
EOFRun it on the remote host by feeding stdin — do not use -n here:
ssh -o BatchMode=yes localhost 'bash -s -- 90' < check_disk.shSample output:
root used 62% (threshold 90%)The remote bash read the script from SSH stdin and received 90 as $1. The overall SSH exit code reflects the script's final (( used < threshold )) test (0 when usage is below the threshold).
Run sudo Commands Non-Interactively
A common multi-server command is ssh host 'sudo systemctl restart nginx'. Even with BatchMode=yes, SSH being non-interactive does not guarantee that remote sudo will skip its own password prompt.
sudo -n tells sudo not to prompt for credentials. If authentication would require interaction, sudo exits with an error instead:
ssh -n -o BatchMode=yes localhost 'sudo -n systemctl is-active sshd'Sample output:
activeFor unattended automation, grant narrowly scoped NOPASSWD access only to the commands the account needs.
When Bash + SSH Is No Longer Enough
Bash and SSH work well for direct remote commands and small automation tasks. Consider Ansible or another orchestration tool when you need idempotent configuration, inventory groups, retries, rolling updates, secret management, or structured reporting.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Loop stops after first host | ssh consumed stdin the loop also reads |
Add ssh -n or redirect SSH stdin from /dev/null |
| Script hangs on one host during connect | No connect timeout | Set -o ConnectTimeout=N |
| SSH prompts when script runs unattended | BatchMode missing, key unavailable, or interactive auth required |
Configure key or agent access and use BatchMode=yes |
| SSH connection becomes unresponsive | Broken or stalled connection | Configure ServerAliveInterval and ServerAliveCountMax |
| Remote command runs too long | Command itself has no time limit | Run timeout on the remote host |
Host key verification failed on new host |
Strict checking, empty known_hosts |
Bootstrap with accept-new or pre-seed keys |
REMOTE HOST IDENTIFICATION HAS CHANGED |
Server key rotated or MITM | Verify fingerprint, update known_hosts, investigate |
| Remote command failed but loop continues | Exit code not checked | Capture $? per host into a summary |
bash -s runs empty or wrong script |
Used -n while piping script on stdin |
Drop -n for bash -s / heredoc paths |
sudo hangs in SSH loop |
Remote sudo wants a password | Use sudo -n and configure passwordless sudo where required |
Summary
To run one command across multiple servers, read the hosts file with while read, use ssh -n so SSH does not read from the same stdin as the hosts loop, and enable BatchMode=yes so authentication failures do not wait for user input. Set ConnectTimeout so connection attempts cannot stall the whole loop for too long, and record each SSH exit status separately.
Keep repeated connection settings in ~/.ssh/config. For larger host lists, limit concurrency rather than opening every connection at once. When the remote command grows beyond a few simple commands, send a Bash script over SSH instead of maintaining a long quoted command string. Move to Ansible or another orchestration tool when you need inventory, idempotency, retries, and structured configuration management.
References
- OpenSSH ssh manual — options, exit status, and multiplexing
- OpenSSH ssh_config manual —
BatchMode,ConnectTimeout,StrictHostKeyChecking,ControlMaster,ServerAliveInterval - Red Hat: Generating SSH keys — key-based authentication setup
Frequently Asked Questions
Why does my SSH loop stop after one host?
SSH reads from stdin by default. When the loop also reads hosts.txt from stdin, an ssh call may consume input that the next read expects, causing later hosts to be skipped. Add ssh -n or redirect SSH's stdin from /dev/null.
What does ssh -n do?
It redirects SSH's stdin from /dev/null. Use it for remote commands that do not need local stdin. Skip -n when you pipe a script into bash -s on the remote host.
How do I prevent password prompts?
Use SSH keys and -o BatchMode=yes. If authentication requires interactive input, SSH fails instead of waiting for a password or passphrase; authentication failures normally return 255. Load passphrase-protected keys into ssh-agent before unattended runs.
Is StrictHostKeyChecking=no safe?
No for production. It weakens host-key verification and is a poor default for automation. Prefer pre-seeded known_hosts with verified fingerprints, or accept-new for first contact, and investigate REMOTE HOST IDENTIFICATION HAS CHANGED errors.
How do I get remote command status?
Check $? immediately after ssh returns. If the remote command ran, ssh returns that command's exit status. SSH itself uses 255 for SSH-level errors, but a remote command can also exit 255, so the code alone does not always distinguish the two cases.

