Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

Run remote commands over SSH

By updated

Improve this page

An SSH connection does not mean opening an interactive shell. By passing a command directly to ssh, you can query, change, restart services or collect information on dozens of servers without ever opening a remote terminal.

It is the basis of system automation, and the direct follow-up of the RHCSA objective "Access remote systems using SSH". The outputs of this page were measured on Debian 13 with OpenSSH 10.0, three host aliases pointing to a test sshd.

In which context?

This technique is used as soon as you must act on a remote server without opening an interactive session: check the disk space after a deployment, restart a service, collect metrics on several machines at once, or run a provisioning script without copying it first. It is also the foundation of automation with tools like Ansible, which does nothing more than wrap SSH commands.


What you will learn

  • Run a command on a server without an interactive session
  • Combine ssh with pipes to process data on the fly
  • Run commands that need sudo remotely
  • Write a multi-server loop for batch operations
  • Pass local scripts to a remote server through stdin
  • Diagnose the cases where the remote command silently fails

Simple command on a remote server

The basic syntax is ssh [options] user@host "command". As soon as an argument follows the host name, ssh stops opening an interactive shell: it runs the command, returns its output and closes the connection. The remote shell then starts in non-interactive mode, which has an important practical consequence: neither ~/.bashrc nor the prompt are loaded, and the PATH may differ from that of an ordinary session.

Terminal window
# Remote kernel version
ssh bob@192.168.1.10 "uname -r"
# Check the disk space
ssh bob@192.168.1.10 "df -h /"
# See the nginx processes, without the search counting itself
ssh bob@192.168.1.10 "pgrep -a nginx"

The result shows directly in your local terminal. No session, no remote prompt.


Pipes between the local machine and a remote server

ssh reads its standard input and writes to its standard output like any command: it therefore fits in the middle of an ordinary shell pipeline. The whole difficulty is knowing on which side each piece of the pipeline runs. What is inside the quotes runs on the server, what is outside runs on your machine.

Read a remote file and filter locally

The grep is placed here after the pipe, so outside the quotes: it works on your machine, on the stream returned by the server.

Terminal window
# Fetch the remote /etc/hosts and filter the web lines
ssh bob@192.168.1.10 "cat /etc/hosts" | grep web

Send local data to the server

The reverse direction works too: the - of tar czf - writes the archive to standard output, which ssh passes to the standard input of the remote tar. Nothing touches an intermediate disk, which avoids planning space for a temporary file.

Terminal window
# Compress locally and extract remotely (alternative to rsync)
tar czf - /data/backups | ssh bob@192.168.1.10 "tar xzf - -C /restore/"

Read several remote files with wildcards

A pattern such as *.log must stay protected by the quotes: otherwise your local shell tries to resolve it against your own files, and sends the server either a wrong path or the raw pattern depending on the files present in the current directory.

Terminal window
# Wildcards must be quoted to be evaluated remotely
ssh bob@192.168.1.10 "cat /var/log/nginx/*.log" | grep "500"

Commands that need sudo

Privilege escalation is the first obstacle met in remote execution, because a command passed to ssh has no terminal by default. Yet many sudo configurations refuse to read a password other than on a terminal, through the requiretty option or simply because no other input channel exists. Two answers exist: allocate a pseudo-terminal, or do without a password altogether.

Pseudo-TTY with -t

sudo on some systems requires a terminal (TTY). The -t option allocates a pseudo-TTY:

Terminal window
ssh -t bob@192.168.1.10 "sudo systemctl restart nginx"

Without -t, sudo refuses to ask for the password and answers (measured on Debian 13): sudo: a terminal is required to read the password; either use the -S option to read from standard input or configure an askpass helper.

Run a local script as root

Sending a local script to a privileged bash only works with NOPASSWD. When ssh's standard input is a pipe or a file, it cannot serve as a terminal at the same time: -t no longer has any effect, and sudo fails with the same message as above (measured with cat local_script.sh | ssh -t ... "sudo bash"). With a NOPASSWD rule for this account, the correct form is:

Terminal window
# The local script is read by a bash started with sudo, without being copied
ssh bob@192.168.1.10 "sudo bash -s" < local_script.sh

Pass a local script remotely

Rather than copying a script to the server, pass it directly through stdin. The -s option of bash asks it to read the script from its standard input instead of waiting for a file name, which lets you keep the arguments passed on the command line. No file is placed on the target machine, so no cleanup to plan and no trace left in /tmp.

Terminal window
# Run a local script on the remote server
ssh bob@192.168.1.10 "bash -s" < my_script.sh

With arguments:

Terminal window
ssh bob@192.168.1.10 "bash -s" < my_script.sh -- --env production --dry-run

Batch operations on several servers

A shell loop around ssh is enough to apply the same command to a whole fleet, without installing any extra tool. It runs sequentially: each host waits for the previous one to finish, and an unreachable server makes the whole loop wait until the connection timeout expires. It is acceptable on a few machines, much less beyond.

Simple loop

The list of hosts is hard-coded in the loop, and the echo serves as a visual separator: without it, the outputs of the different servers blend together.

Terminal window
for host in web1 web2 web3 db1; do
echo "=== $host ==="
ssh bob@$host "uptime && df -h /"
done

Loop over a list file

Moving the list to a file avoids editing the script at each fleet change. The trap is systematic: ssh reads the loop's standard input, that is the rest of the file, even when the remote command does not need it. Measured with three hosts in servers.txt: without an option, only the first one is processed; with ssh -n, which connects ssh's input to /dev/null, all three are.

Terminal window
# servers.txt holds one host per line
while read -r host; do
echo "=== $host ==="
ssh -n bob@$host "free -h | grep Mem"
done < servers.txt

Multi-server system collection (CSV format)

Producing CSV makes the output usable by a spreadsheet or an analysis script, provided you write one line per host. The safest way is to build the whole line on the server with a single printf, between single quotes: no variable is then expanded by your local shell. The load comes from /proc/loadavg, the memory from the available column of free, more telling than free.

Terminal window
echo "HOST,LOAD_1MIN,MEM_AVAILABLE_MB,DISK_FREE"
for host in web1 web2 web3; do
ssh -n bob@$host 'printf "%s,%s,%s,%s\n" "$(hostname)" "$(cut -d" " -f1 /proc/loadavg)" "$(free -m | awk "/Mem/{print \$7}")" "$(df -h / | awk "NR==2{print \$4}")"'
done

Measured output, one clean line per server (the three aliases pointed to the same test machine):

HOST,LOAD_1MIN,MEM_AVAILABLE_MB,DISK_FREE
4161584e2fbc,1.94,24117,264G
4161584e2fbc,1.94,24116,264G

A shorter version that chains echo -n, uptime and free actually produces three lines per host, each command ending its output with a line break: it is the classic flaw of this kind of collection.


Exit codes and error handling

ssh passes on the exit code of the remote command, directly usable in a shell test. One nuance matters for writing reliable scripts: ssh reserves code 255 for its own errors (unreachable host, authentication refused, key rejected). A 255 code therefore says nothing about the requested command, it signals that the connection itself failed.

Terminal window
if ssh bob@192.168.1.10 "systemctl is-active nginx" > /dev/null 2>&1; then
echo "nginx active on 192.168.1.10"
else
echo "ALERT: nginx inactive on 192.168.1.10"
fi

Stop immediately if a command fails in a loop:

Terminal window
set -e
for host in web1 web2 web3; do
ssh bob@$host "test -f /var/lock/deploy.lock && exit 1 || true"
done

Tunnelling + remote command

A remote command can also run on a machine you do not reach directly. The -J option (ProxyJump) bounces the connection through a bastion: the traffic travels end-to-end encrypted, and the bastion only sees a stream it cannot decrypt. Your private key stays on your workstation, it is never copied to the intermediate machine.

Terminal window
# Through ProxyJump in a one-liner: command on a production server reachable via a bastion
ssh -J bob@bastion.example.com bob@10.0.1.15 "systemctl status app"

Troubleshooting

Almost every remote execution failure comes from the same misunderstanding: the command was not interpreted where you thought, or the environment of the non-interactive shell differs from your usual session. Before looking further, check that the command is properly surrounded by quotes and test it in an SSH session opened by hand.

SymptomLikely causeAction
sudo: a terminal is requiredsudo requires a TTYAdd -t to the ssh command, or NOPASSWD for a script
The command runs locallyQuotes forgotten around the commandSurround the command with "..."
A variable has the local value$ inside double quotes is expanded locallyUse single quotes '...'
bash: command: not found, outside a sessionDifferent PATH in non-interactive modeUse the absolute path or bash -l -c "..."
Unexpected exit code 255255 is ssh's own error code: the connection failed before runningCheck network access, the key and the host name; a real remote 255 is indistinguishable
A while read loop stops after the first hostssh consumes the loop's standard inputAdd -n: ssh -n ...
Command stuck foreverRemote program waits for inputAdd -n to turn off stdin: ssh -n ...

Key points

  • ssh user@host "command" runs without opening a session, the command must be in quotes
  • Double quotes protect | and *, not $: single quotes for remote variables
  • -t allocates a pseudo-TTY needed for interactive sudo
  • bash -s < script.sh runs a local script remotely without copying it
  • The remote command's exit code is passed on by SSH, use it in your scripts; 255 signals a failure of the connection itself
  • In a while read loop, ssh -n is mandatory, otherwise only the first host is processed
  • For large-scale batch, ControlMaster or Ansible are better suited than for loops

Knowledge check

Check that the essentials of this guide stuck. The questions cover only what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn