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
sshwith 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.
# Remote kernel versionssh bob@192.168.1.10 "uname -r"
# Check the disk spacessh bob@192.168.1.10 "df -h /"
# See the nginx processes, without the search counting itselfssh 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.
# Fetch the remote /etc/hosts and filter the web linesssh bob@192.168.1.10 "cat /etc/hosts" | grep webSend 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.
# 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.
# Wildcards must be quoted to be evaluated remotelyssh 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:
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:
# The local script is read by a bash started with sudo, without being copiedssh bob@192.168.1.10 "sudo bash -s" < local_script.shPass 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.
# Run a local script on the remote serverssh bob@192.168.1.10 "bash -s" < my_script.shWith arguments:
ssh bob@192.168.1.10 "bash -s" < my_script.sh -- --env production --dry-runBatch 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.
for host in web1 web2 web3 db1; do echo "=== $host ===" ssh bob@$host "uptime && df -h /"doneLoop 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.
# servers.txt holds one host per linewhile read -r host; do echo "=== $host ===" ssh -n bob@$host "free -h | grep Mem"done < servers.txtMulti-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.
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}")"'doneMeasured output, one clean line per server (the three aliases pointed to the same test machine):
HOST,LOAD_1MIN,MEM_AVAILABLE_MB,DISK_FREE4161584e2fbc,1.94,24117,264G4161584e2fbc,1.94,24116,264GA 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.
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"fiStop immediately if a command fails in a loop:
set -efor host in web1 web2 web3; do ssh bob@$host "test -f /var/lock/deploy.lock && exit 1 || true"doneTunnelling + 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.
# Through ProxyJump in a one-liner: command on a production server reachable via a bastionssh -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.
| Symptom | Likely cause | Action |
|---|---|---|
sudo: a terminal is required | sudo requires a TTY | Add -t to the ssh command, or NOPASSWD for a script |
| The command runs locally | Quotes forgotten around the command | Surround the command with "..." |
| A variable has the local value | $ inside double quotes is expanded locally | Use single quotes '...' |
bash: command: not found, outside a session | Different PATH in non-interactive mode | Use the absolute path or bash -l -c "..." |
| Unexpected exit code 255 | 255 is ssh's own error code: the connection failed before running | Check network access, the key and the host name; a real remote 255 is indistinguishable |
A while read loop stops after the first host | ssh consumes the loop's standard input | Add -n: ssh -n ... |
| Command stuck forever | Remote program waits for input | Add -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 -tallocates a pseudo-TTY needed for interactivesudobash -s < script.shruns 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 readloop,ssh -nis mandatory, otherwise only the first host is processed - For large-scale batch, ControlMaster or Ansible are better suited than
forloops
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
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
Review
(0/0)Score by domain
A domain groups several guides and gets several questions, so its score is more reliable than a per-topic score, which often rests on a single question.
Summary
Strengths
No topic passed at 80% on at least two questions yet.
Weak areas
No topic below the pass mark.
Recommended next
Hints before you try again?
Continue the course: SSH tunnels: -L, -R, -D and ProxyJump
A new full quiz with random questions
Work again only on the questions you missed