
A Bash Wrapper to Run One Command Across Many Hosts Over SSH
Before you run this
This is a small bash wrapper that reads a list of hostnames from a file and runs the same command on all of them over SSH, several at a time in parallel, tagging every line of output with the host it came from. The purpose is to save you from looping through servers by hand when you want to check a value, restart a service, or gather a fact fleet-wide.
The wrapper itself needs no elevated privileges locally — run it as your normal user. Whether it needs privilege remotely depends entirely on the command you pass it: uptime does not, systemctl restart nginx does. If your command needs root on the far side, you supply that (via a sudo command that is configured for non-interactive use, or by connecting as an appropriately privileged user). Don't assume passwordless sudo exists everywhere just because it exists on one box.
This script is only as safe as the command you hand it. Running apt-get upgrade -y or rm across fifty hosts in parallel is exactly as irreversible as it sounds, and it happens fifty times before you can hit Ctrl-C. So:
- Read the script before running it. Understand what it passes to
ssh. - Start read-only. Prove your host list and connectivity with something harmless like
hostnameoruptimefirst. - Use the built-in dry-run (
DRYRUN=1, below) to see exactly what would be executed before you execute it. - Test against one or two hosts — a short hosts file — before you point it at the whole fleet.
- For any command that changes or deletes state, remember there is no undo built into this wrapper. Rollback is whatever your command's own reversal would be, run again through the same wrapper.
I assume Debian 12 / Ubuntu 22.04, GNU bash, the standard OpenSSH client, and key-based authentication already working to every host in your list (test with ssh user@host by hand first). On RHEL/Alma the script is identical; only your package names differ if you install extras.
The approach
The mainstream, no-extra-packages way to fan out over SSH is xargs -P, which runs N jobs at a time. The other common tool is GNU parallel, which gives nicer output grouping and job control — install it with apt install parallel if you prefer it — but I'll write the xargs version here because it's present on a stock system and the logic is transparent.
Two things make parallel SSH behave: -o BatchMode=yes so a host that would prompt for a password fails fast instead of hanging forever, and -o ConnectTimeout so an unreachable host doesn't stall the whole run.
The hosts file
Plain text, one host per line. Comments and blank lines are ignored by the script.
# hosts.txt
web01.example.com
web02.example.com
db01.example.com
The wrapper
Save this as fanout.sh and chmod +x fanout.sh.
#!/usr/bin/env bash
# fanout.sh — run one command on many hosts over SSH, in parallel.
# Usage: ./fanout.sh hosts.txt 'command to run'
# Dry run: DRYRUN=1 ./fanout.sh hosts.txt 'command to run'
# Override user/parallelism: SSH_USER=admin PARALLEL=5 ./fanout.sh ...
set -uo pipefail
HOSTS_FILE="${1:?usage: $0 hosts.txt 'command'}"
REMOTE_CMD="${2:?provide a command to run}"
PARALLEL="${PARALLEL:-10}" # how many hosts to hit at once
SSH_USER="${SSH_USER:-$USER}" # defaults to your local username
run_one() {
local host="$1" cmd="$2"
if [[ -n "${DRYRUN:-}" ]]; then
# Show exactly what would run, quoted, without connecting.
printf '%s\twould run: ssh %s@%s %q\n' "$host" "$SSH_USER" "$host" "$cmd"
return 0
fi
local out rc
out=$(ssh -o BatchMode=yes -o ConnectTimeout=10 \
-o StrictHostKeyChecking=accept-new \
"${SSH_USER}@${host}" "$cmd" 2>&1)
rc=$?
# Prefix every output line with the host so parallel output stays readable.
while IFS= read -r line; do
printf '%s\t%s\n' "$host" "$line"
done <<< "$out"
printf '%s\tEXIT=%d\n' "$host" "$rc"
}
export -f run_one
export SSH_USER DRYRUN
# Strip comments and blank lines, then hand one host at a time to xargs.
grep -vE '^\s*(#|$)' "$HOSTS_FILE" \
| xargs -P "$PARALLEL" -I HOST \
bash -c 'run_one "$@"' _ HOST "$REMOTE_CMD"
A few notes on the non-obvious lines:
StrictHostKeyChecking=accept-new(OpenSSH 7.6+) accepts and records new host keys automatically but still refuses a host whose key has changed — the sane middle ground for a fleet. If you'd rather vet every key by hand, remove that option and connect to each host once manually first.2>&1folds remote stderr into the captured output so error messages get the host prefix too.export -f run_onemakes the function available to thebash -cthatxargsspawns for each host.- The
EXIT=line at the end of each host's block is your success signal — read it, don't just eyeball the output.
Running it
Prove the host list and connectivity first, with the dry run:
DRYRUN=1 ./fanout.sh hosts.txt 'uptime'
Then run it for real, read-only:
./fanout.sh hosts.txt 'uptime'
Output looks like this, one tab-separated block per host, interleaved because it's parallel:
web01.example.com 14:22:31 up 40 days, 3:11, 1 user, load average: 0.08, 0.06, 0.01
web01.example.com EXIT=0
db01.example.com 14:22:31 up 12 days, 9:02, 0 users, load average: 0.30, 0.22, 0.18
db01.example.com EXIT=0
Turn parallelism down while testing, or up once you trust it:
PARALLEL=5 SSH_USER=admin ./fanout.sh hosts.txt 'systemctl is-active nginx'
If your command genuinely needs root on the far side and you have non-interactive sudo configured, pass it inside the command string — for example 'sudo systemctl restart nginx'. Confirm your sudoers actually permits that without a TTY before you rely on it; if it prompts, BatchMode=yes will make that host fail rather than hang, which is the behaviour you want.
Verifying it worked
The point of the EXIT= marker is that "no error scrolled past" is not proof. Pull out any host that returned non-zero:
./fanout.sh hosts.txt 'systemctl is-active nginx' | grep -E 'EXIT=[^0]'
An empty result there means every host exited 0. To see the actual reported state per host, filter to the value lines instead:
./fanout.sh hosts.txt 'systemctl is-active nginx' | grep -v 'EXIT='
For a change you made — say you restarted a service — verify with a separate read-only run rather than trusting the change command's own output:
./fanout.sh hosts.txt 'systemctl is-active nginx'
Undo
There is no rollback inside this wrapper — it just relays whatever you told it. If the command you ran was read-only, there is nothing to undo. If it changed state, the reversal is the opposite command, sent the same way. For example, if you had enabled a unit fleet-wide:
# Undo an earlier: 'sudo systemctl enable --now some.service'
./fanout.sh hosts.txt 'sudo systemctl disable --now some.service'
Because there's no automatic rollback, this is exactly why the dry run and the small test list matter: on a parallel fan-out, the cheapest safety mechanism is looking before you leap.
If you outgrow this
When you find yourself wanting per-host output grouping, retries, or a live progress bar, that's the signal to move to GNU parallel for ad-hoc runs, or to Ansible (its command/shell modules and ansible -m ping for connectivity) once your fleet actions become repeatable and worth version-controlling. For the syntax of the SSH options used above, man ssh_config on your own box is the authoritative reference — BatchMode, ConnectTimeout, and StrictHostKeyChecking are all documented there.
Runs enterprise networks and security for a living, and writes Shore Up to turn two decades of hands-on Linux, Windows and mail-server work into guides you can actually use.
More about the author →Was this article helpful?
Tap a star — no sign-in needed.
Be the first to rate this article.
Related guides
Inventory Installed Packages and Services Across Servers over SSH
This guide gives you a small bash script that logs into a list of servers over SSH, and on each one dumps two things to a text file on your control machine: the list of installed packages (via dpkg-query on Debian/Ubuntu or rpm on RHEL/Alma) and the list of systemd service unit files. The purpose is a point-in-time software and service inventory you can diff, audit, or archive.
Bulk-Update BIND Zone Files and Reload Safely with a Script
This script applies one literal find-and-replace across every zone file in a directory (for example, retiring an old IP or NS name), bumps each changed zone's SOA serial so slaves pick up the change, validates every touched zone, and reloads BIND only if all of them pass . If any zone fails validation it restores the backups and aborts before touching the running server.
Batch-Convert and Optimise Images with a Watch Script
This sets up a background service that watches a directory and, whenever a new JPEG or PNG lands there, produces a resized, stripped, and re-compressed copy in a separate output directory. Its purpose is to keep uploaded or generated images small and consistent without anyone running a command by hand.
Harden SSH Across a Fleet with Ansible
This playbook drops a single sshd configuration file ( /etc/ssh/sshd_config.d/99-hardening.conf ) onto every host in your inventory and reloads the SSH service. It disables root login, disables password authentication, requires public-key auth, and tightens a handful of session and auth limits.




