Run SSH Commands on Multiple Servers with Bash

Tested on RHEL 10.2 (Coughlan)
Package bash-5.2.26-6.el10.x86_64
openssh-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
Short on time?Beta Get the gist of this 17-minute read in seconds, then ask follow-up questions

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:

bash
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.txt

An 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:

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:

bash
ssh -o BatchMode=yes -o PreferredAuthentications=password -o PubkeyAuthentication=no nonexistent@localhost 'hostname'
rc=$?
printf 'ssh exit: %d\n' "$rc"

Sample output:

output
nonexistent@localhost: Permission denied (publickey,password).
ssh exit: 255

SSH 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:

text
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:

bash
ssh -o BatchMode=yes localhost 'hostname'

Sample output:

output
vm1.lab.example

The 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:

bash
ssh -n -o BatchMode=yes localhost 'hostname; uptime -p; df -h / | tail -1'

Sample output:

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:

bash
ssh -n -o BatchMode=yes localhost 'false && echo never'
rc=$?
printf 'ssh exit: %d\n' "$rc"

Sample output:

output
ssh exit: 1

Because 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:

bash
printf '# lab hosts\nlocalhost\n127.0.0.1\n' > hosts.txt

Confirm the file content before you loop:

bash
cat hosts.txt

Sample output:

output
# lab hosts
localhost
127.0.0.1

Replace 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:

bash
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]}"
done

Sample output:

output
up 8 hours, 2 minutes
up 8 hours, 2 minutes
HOST            STATUS
localhost       ok
127.0.0.1       ok

uptime -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:

bash
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.txt

Sample output:

output
SSH to: localhost
vm1.lab.example

The 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:

bash
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.txt

Sample output:

output
SSH to: localhost
vm1.lab.example
SSH to: 127.0.0.1
vm1.lab.example

Do 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:

bash
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:

bash
time ssh -o BatchMode=yes -o ConnectTimeout=2 192.0.2.99 'hostname'

Example output from the RHEL test host:

output
ssh: connect to host 192.0.2.99 port 22: Connection timed out

real	0m2.091s
user	0m0.010s
sys	0m0.014s

Some 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:

text
ServerAliveInterval 30
ServerAliveCountMax 3

Add 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:

bash
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:

bash
ssh -n -o BatchMode=yes localhost 'exit 42'
rc=$?
printf 'ssh exit: %d\n' "$rc"

Sample output:

output
ssh exit: 42

Compare that with an SSH-level failure (exact message varies by network):

bash
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:

output
ssh: connect to host 192.0.2.99 port 22: Connection timed out
ssh exit: 255

A 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:

bash
ssh -n localhost "echo local HOME=$HOME"

Sample output:

output
local HOME=/home/admin

Single quotes preserve $HOME for the remote shell:

bash
ssh -n localhost 'echo "remote HOME=$HOME"'

Sample output:

output
remote HOME=/home/admin

Use "$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:

bash
ssh -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile=/tmp/lab_known_hosts -o BatchMode=yes 127.0.0.1 'hostname'

Sample output:

output
Warning: Permanently added '127.0.0.1' (ED25519) to the list of known hosts.
vm1.lab.example

accept-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:

bash
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):

output
@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@
@    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:

bash
mkdir -p ~/.ssh/control
chmod 700 ~/.ssh/control

A Host entry is an alias; HostName defines the actual destination. This example defines lab-local as an alias for localhost:

text
Host lab-local
    HostName localhost
    User admin
    IdentityFile ~/.ssh/id_ed25519
    BatchMode yes
    ConnectTimeout 10
    ControlMaster auto
    ControlPersist 30s
    ControlPath ~/.ssh/control/%C

Then 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:

bash
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]}"
done

Sample output:

output
localhost ssh_exit=0
127.0.0.1 ssh_exit=0

Each 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:

bash
ssh -n lab-local 'echo first'

Sample output:

output
first

A second call while the master socket from ControlPersist is still alive reuses the connection:

bash
ssh -n lab-local 'echo second via mux'

Sample output:

output
second via mux

ControlPersist=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:

bash
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 ))
EOF

Run it on the remote host by feeding stdin — do not use -n here:

bash
ssh -o BatchMode=yes localhost 'bash -s -- 90' < check_disk.sh

Sample output:

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:

bash
ssh -n -o BatchMode=yes localhost 'sudo -n systemctl is-active sshd'

Sample output:

output
active

For 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


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.

Finished reading?Beta Get the key takeaways in seconds, then ask follow-up questions
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with 15+ years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)