Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Bash & the POSIX Shell  Module 17 – Scripts in the real world

Remote work: ssh, rsync and curl in scripts

Run remote commands with ssh that survive two parses and fail fast, copy only what changed with rsync, and call APIs with curl that stop on every error.

  • Intermediate
  • 20 minutes
  • Examples run with Bash 5.3.20, dash 0.5.13.5, shellcheck 0.11.0 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 3 interview questions.

What you will learn

  • Run a command on another host with ssh so that every argument survives both the local and the remote shell
  • Configure ssh in a script to give up within seconds instead of waiting, and tell status 255 from the remote status
  • Choose between scp, sftp and rsync, and predict what the quick check and the delta of rsync will send
  • Call an HTTP API with curl so that error codes, stalls and unexpected JSON answers stop the script
  • Check whether a TCP port accepts connections using only Bash's /dev/tcp and timeout

Before you start

What you’ll learn · Before you start

What you will learn

  • Run a command on another host with ssh so that every argument survives both the local and the remote shell
  • Configure ssh in a script to give up within seconds instead of waiting, and tell status 255 from the remote status
  • Choose between scp, sftp and rsync, and predict what the quick check and the delta of rsync will send
  • Call an HTTP API with curl so that error codes, stalls and unexpected JSON answers stop the script
  • Check whether a TCP port accepts connections using only Bash's /dev/tcp and timeout

Before you start

On this page

ssh web1 wc -l "$f" looks like one command, yet with f set to sales 2027.csv the far end looks for two files, sales and 2027.csv, and fails with status 1. Scripts that reach other computers break in ways a local script cannot: a second shell re-reads your words, a copy re-sends 260,000 bytes where 700 would do, a host that stops answering holds the script until someone notices, and curl exits 0 on an HTTP 404.

Every one of those cases runs in this lesson’s sessions on one Mac, against servers on 127.0.0.1 and a local stand-in for the far end of ssh: the two parses of an ssh command and the two ways to protect your arguments, the options that make ssh give up in seconds, what rsync decides to send and how little of a file it sends, the statuses curl and jq leave for a script to read, and a port check with nothing but Bash.

ssh sends one string, and the far end’s shell parses it again

Your shell splits a command line into words and removes the quotes, so ssh receives tidy arguments. It does not send them as a list. The ssh(1) manual says that any arguments after the command are appended to it, separated by spaces, before the line goes to the server. On the far side, sshd(8) runs that line through the account’s login shell with its -c option, and that shell splits, expands and runs it like any line it reads. Every word of a remote command: A command line given to ssh after the host name. ssh joins its words with spaces and sends one string, which the far account's login shell parses again before running it, so its quoting must survive two shells. is parsed twice: once by your shell and once by a shell you never see.

This Mac runs no SSH server, so the session plays the far end with a small function, far_end, that does exactly those two things: it joins its arguments with spaces, prints the result and hands it to a second Bash with bash -c. Everything else is a real run of Bash 5.3.20:

A name with a space and a value with a semicolon, through a stand-in for the far end Python · two_parses_session.py
"""ssh joins its words into one string and the far end's shell parses that string again.

This Mac runs no SSH server, so the function far_end plays the far side: it joins its arguments with spaces, which is
what ssh(1) says the client does with a command and its arguments, and hands that one string to a second Bash, which
is what sshd(8) does with it (it runs the user's shell with -c). Everything else is real Bash 5.3.20.
"""
from session import run, write

write("sales 2027.csv", "north,120\nsouth,95\neast,143\n")
write(
    "count.sh",
    "#!/bin/bash\n"
    "# count.sh: sent on standard input, so the far end's login shell never parses its text\n"
    "printf 'counting %s\\n' \"$1\"\n"
    "wc -l < \"$1\"\n",
)

run(r"""
# Here, the quotes keep the name whole: wc gets -l and one file name.
f='sales 2027.csv'
wc -l "$f"
# far_end stands in for ssh plus the remote shell: join the words with spaces, then parse the string again.
far_end() { local IFS=' '; printf 'far end runs: %s\n' "$*"; bash -c "$*"; }
!far_end wc -l "$f"
printf -v q '%q' "$f"
echo "$q"
far_end wc -l "$q"
# A semicolon in a value becomes a second command on the far end.
g='q3.csv; touch MARKER'
far_end wc -l "$g"
ls
rm MARKER
printf -v qg '%q' "$g"
!far_end wc -l "$qg"
ls
# Sending the script on standard input keeps its text away from the far shell, but not the words after bash -s.
cat count.sh
!far_end bash -s -- "$f" < count.sh
far_end bash -s -- "$q" < count.sh
""")

Output

# Here, the quotes keep the name whole: wc gets -l and one file name.
$ f='sales 2027.csv'
$ wc -l "$f"
       3 sales 2027.csv
# far_end stands in for ssh plus the remote shell: join the words with spaces, then parse the string again.
$ far_end() { local IFS=' '; printf 'far end runs: %s\n' "$*"; bash -c "$*"; }
$ far_end wc -l "$f"
far end runs: wc -l sales 2027.csv
wc: sales: open: No such file or directory
wc: 2027.csv: open: No such file or directory
       0 total
[exit status 1]
$ printf -v q '%q' "$f"
$ echo "$q"
sales\ 2027.csv
$ far_end wc -l "$q"
far end runs: wc -l sales\ 2027.csv
       3 sales 2027.csv
# A semicolon in a value becomes a second command on the far end.
$ g='q3.csv; touch MARKER'
$ far_end wc -l "$g"
far end runs: wc -l q3.csv; touch MARKER
wc: q3.csv: open: No such file or directory
$ ls
MARKER
count.sh
sales 2027.csv
$ rm MARKER
$ printf -v qg '%q' "$g"
$ far_end wc -l "$qg"
far end runs: wc -l q3.csv\;\ touch\ MARKER
wc: q3.csv; touch MARKER: open: No such file or directory
[exit status 1]
$ ls
count.sh
sales 2027.csv
# Sending the script on standard input keeps its text away from the far shell, but not the words after bash -s.
$ cat count.sh
#!/bin/bash
# count.sh: sent on standard input, so the far end's login shell never parses its text
printf 'counting %s\n' "$1"
wc -l < "$1"
$ far_end bash -s -- "$f" < count.sh
far end runs: bash -s -- sales 2027.csv
counting sales
bash: line 4: sales: No such file or directory
[exit status 1]
$ far_end bash -s -- "$q" < count.sh
far end runs: bash -s -- sales\ 2027.csv
counting sales 2027.csv
       3

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 two_parses_session.py

Locally, wc -l "$f" counted 3 lines. Through far_end, the same words became the string wc -l sales 2027.csv, and the second shell gave wc two names that do not exist. The value q3.csv; touch MARKER did worse: the semicolon ended the first command on the far end, touch MARKER ran as a second one, and the next ls listed MARKER. That is command injection: A bug where user text is read as shell: a value such as x; touch MARKER arrives through eval, sh -c or unquoted expansion and runs its own commands. The fix is to pass the value as one argument, never splice it into a command string. More in “Injection, eval and untrusted input”, caused by nothing more than a value that holds shell syntax. Step through the first case and the two fixes the session ran:

Interactive · message by message One ssh command, parsed twice Step through ssh web1 wc -l "$f" with f set to a name that holds a space, then through the two fixes the session ran.
One ssh command, parsed twice, step 6 of 6. Messages so far: Your shell to ssh: 4 arguments; ssh to Far shell: one string; Far shell to Your shell: status 1; Your shell to Far shell: sales\ 2027.csv; Your shell to Far shell: count.sh on stdin.Your shellfirst parsesshjoins wordsFar shellwc gets 14 argumentsone stringstatus 1sales\ 2027.csvcount.sh on stdin

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The first parse. You type ssh web1 wc -l "$f". Your shell expands $f inside the double quotes and removes the quotes, so ssh receives four arguments: web1, wc, -l and sales 2027.csv, the last still one word.
  2. ssh joins the words. No quotes are left to send. ssh(1) appends the arguments after the command, separated by spaces, and sends one string: wc -l sales 2027.csv. The edge between sales and 2027.csv is now an ordinary space.
  3. The second parse. sshd(8) hands that string to the account's login shell with -c. The shell splits it at spaces like any command line, so wc receives three arguments: -l, sales and 2027.csv.
  4. Two files that do not exist. wc finds neither sales nor 2027.csv and exits 1, and ssh hands that status back as its own. The session's far_end printed both errors and the same status.
  5. Quote for the far shell. printf %q turns the value into sales\ 2027.csv. Your shell passes that text on as it is, ssh joins it in, and the far shell removes the backslash, so wc gets one name and counts 3 lines.
  6. Or send the script itself. ssh web1 bash -s -- "$q" < count.sh sends the script on standard input, which no shell on the far end parses as a command line. Only the words after bash -s still meet the second parse, so $q stays quoted.

The two fixes protect different things, so a script often uses both:

  • Quote each value for the far shell. printf -v q '%q' "$f" stored sales\ 2027.csv, a form the second shell reads back as the original value; this is a shell escape: Turning a value into text the shell will read back as the same value, with every special character made safe. Bash's printf %q prints a value in this form; the Bash manual says the result "can be reused as shell input". More in “Injection, eval and untrusted input”. With it, wc got one name and counted 3, and the hostile value became one odd file name that wc could not open, with no MARKER made.
  • Send the script on standard input. ssh web1 bash -s -- "$q" < count.sh carries the text of count.sh as data, which no shell on the far end parses as a command line; bash -s reads it from its input and runs it with the words after -- as $1, $2 and so on. Those words still go through the second parse: with "$f" the script received $1 as sales, and only "$q" gave it the whole name.

Wrapping the value in single quotes by hand

Wrong: ssh web1 "wc -l '$f'". It works until a value holds a single quote: it's.csv closes the quote early and the far shell reports a syntax error, or runs whatever follows. Right: printf -v q '%q' "$f", then ssh web1 "wc -l $q", so Bash writes the quoting for every character the value holds.

The rules for turning data into shell input safely, and why eval is the same trap, are in the lesson on eval and untrusted input. Keep the ssh options themselves in a command array: An indexed array that holds a command name and its arguments, one per element, run as "${cmd[@]}" so that each element reaches the command as exactly one word, spaces and quote characters included. More in “Lists done right: arrays, mapfile and read -a”, as in arrays for commands, so each option reaches ssh as one word.

Checkpoint

Check: The joined string and the far end's second parse

  1. Question 1 of 2 With f='sales 2027.csv', a script runs ssh web1 wc -l "$f". What does wc receive on the far end?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Three arguments: -l, sales and 2027.csv

    Two shells parse the line. Yours turns "$f" into one word and drops the quotes; ssh joins wc, -l and that word with spaces; the far shell splits the result again. printf %q, which gave sales\ 2027.csv, keeps the name whole through the second parse.

  2. Question 2 of 2 A script sends its body on standard input: ssh web1 bash -s -- "$f" < count.sh, with f='sales 2027.csv'. What is $1 inside count.sh on the far end?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: sales, because the words after bash -s still pass through the far shell

    Sending the script on standard input keeps its body away from the far shell, but every word on the ssh command line meets the second parse. With q from printf -v q '%q' "$f", the session's count.sh received the whole name and counted 3 lines.

ssh in a script must fail fast, and 255 means ssh itself failed

At a terminal, ssh asks questions: a password, a passphrase, whether to trust a host it has never seen. A script run by cron or CI has nobody to answer them, and a host that has stopped responding can hold its connection open long after anyone would wait. A script therefore logs in by public-key authentication: Logging in by proving you hold a private key whose public half the server lists for the account, in ~/.ssh/authorized_keys for OpenSSH. No password travels, so a script can log in without a prompt.: ssh(1) says the far account’s ~/.ssh/authorized_keys lists the public keys allowed to log in, so the private half never leaves the machine that runs the script. Then a handful of options turn every question into a quick failure:

Option What it does (ssh_config(5)), and what the session set
BatchMode yes Turns off password prompts and host-key questions, so ssh fails instead of asking; set for every call
ConnectTimeout 2 Limits the TCP connection and the SSH handshake, instead of the system’s TCP timeout; the silent host gave up in under 4 seconds
ServerAliveInterval 15 After 15 seconds without data, asks the server for an answer through the encrypted channel; set for web*
ServerAliveCountMax 3 After 3 such requests without a reply, disconnects: about 45 seconds with an interval of 15; 3 is the default
StrictHostKeyChecking yes Never adds a host key by itself and refuses a host whose key changed; accept-new adds new hosts but still refuses changed keys

batch mode: The ssh option BatchMode yes, which turns off every question ssh would ask a person (a password, a passphrase, whether to trust a new host key), so a script gets an error and status 255 instead of a prompt. matters most of all, because it turns “waiting for someone to type” into an error a script can see. The host keys themselves live in a known hosts file: A file of host names and the public host keys ssh expects from them, ~/.ssh/known_hosts by default. ssh checks each server's key against it and refuses to connect, or warns, when the key differs. file; a script that names its own with UserKnownHostsFile checks every host against keys it ships, instead of against whatever a person’s account has collected. The order of a config file matters too. ssh_config(5) says the first value found for each option is the one used, so host-specific blocks go first and the defaults for Host * go last. The session writes such a file, asks ssh -G what a connection would use without connecting, and then connects only to 127.0.0.1: once to a port nothing listens on, and once to a port where a server accepts the connection and then says nothing, as a host that has hung would. The system picks those ports for each run; the session prints them as CLOSED_PORT and SILENT_PORT:

ssh -G, a refused connection and a silent one, each ending with status 255 Python · ssh_fail_fast_session.py
"""ssh options that make a script fail within seconds instead of waiting, and the status 255.

The ssh client really runs here (OpenSSH 10.3p1), but only with the session's own files: -F names the session's
ssh_config (so neither ~/.ssh/config nor /etc/ssh/ssh_config is read), known hosts are a file of the session and
/dev/null, and IdentityFile=none with IdentityAgent=none offers no key at all. IdentityFile=none matters: ssh takes ~
from the account database, not from $HOME, and loads its default keys before it connects, so without it a connection
attempt would look for keys in the real home folder. It only ever connects to 127.0.0.1: to a port nothing listens on, and to a
port where servers.silent() accepts the connection and then says nothing, as a host that has hung would. The ports
are chosen by the system for each run and printed as CLOSED_PORT and SILENT_PORT.
"""
from session import env, mask, run, write
import servers

closed_port = servers.closed()
silent_port = servers.silent()
env("CLOSED_PORT", closed_port)
env("SILENT_PORT", silent_port)
mask(closed_port, "CLOSED_PORT")
mask(silent_port, "SILENT_PORT")

write(
    "ssh_config",
    "# Host blocks first, defaults last: for each option, the first value ssh finds wins.\n"
    "Host web1\n"
    "    HostName 127.0.0.1\n"
    "    User deploy\n"
    "    ConnectTimeout 3\n"
    "\n"
    "Host web*\n"
    "    User admin\n"
    "    ServerAliveInterval 15\n"
    "    ServerAliveCountMax 3\n"
    "\n"
    "Host *\n"
    "    BatchMode yes\n"
    "    ConnectTimeout 10\n"
    "    StrictHostKeyChecking yes\n",
)

run(r"""
cat ssh_config
opts=(-F "$PWD/ssh_config" -o BatchMode=yes -o IdentitiesOnly=yes -o IdentityFile=none -o IdentityAgent=none -o UserKnownHostsFile="$PWD/known_hosts" -o GlobalKnownHostsFile=/dev/null)
# ssh -G prints the settings a connection would use, without connecting.
ssh "${opts[@]}" -G web1 true | grep -E '^(user|hostname|batchmode|connecttimeout|serveraliveinterval|serveralivecountmax|stricthostkeychecking) '
ssh "${opts[@]}" -G web2 true | grep -E '^(user|hostname|connecttimeout) '
# Nothing listens on CLOSED_PORT, so the connection is refused at once.
!ssh "${opts[@]}" -p "$CLOSED_PORT" web1 uptime
# SILENT_PORT accepts the connection and then never answers.
start=$SECONDS
!ssh "${opts[@]}" -o ConnectTimeout=2 -p "$SILENT_PORT" web1 uptime
(( SECONDS - start < 4 )) && echo "gave up in under 4 seconds"
# 255 is ssh's own failure; any other status came from the remote command.
on() { ssh "${opts[@]}" "$@"; local rc=$?; if (( rc == 255 )); then echo "ssh failed, so no command ran" >&2; fi; return "$rc"; }
!on -p "$CLOSED_PORT" web1 uptime
ls
""")

Output

$ cat ssh_config
# Host blocks first, defaults last: for each option, the first value ssh finds wins.
Host web1
    HostName 127.0.0.1
    User deploy
    ConnectTimeout 3

Host web*
    User admin
    ServerAliveInterval 15
    ServerAliveCountMax 3

Host *
    BatchMode yes
    ConnectTimeout 10
    StrictHostKeyChecking yes
$ opts=(-F "$PWD/ssh_config" -o BatchMode=yes -o IdentitiesOnly=yes -o IdentityFile=none -o IdentityAgent=none -o UserKnownHostsFile="$PWD/known_hosts" -o GlobalKnownHostsFile=/dev/null)
# ssh -G prints the settings a connection would use, without connecting.
$ ssh "${opts[@]}" -G web1 true | grep -E '^(user|hostname|batchmode|connecttimeout|serveraliveinterval|serveralivecountmax|stricthostkeychecking) '
user deploy
hostname 127.0.0.1
batchmode yes
stricthostkeychecking true
serveralivecountmax 3
serveraliveinterval 15
connecttimeout 3
$ ssh "${opts[@]}" -G web2 true | grep -E '^(user|hostname|connecttimeout) '
user admin
hostname web2
connecttimeout 10
# Nothing listens on CLOSED_PORT, so the connection is refused at once.
$ ssh "${opts[@]}" -p "$CLOSED_PORT" web1 uptime
ssh: connect to host 127.0.0.1 port CLOSED_PORT: Connection refused
[exit status 255]
# SILENT_PORT accepts the connection and then never answers.
$ start=$SECONDS
$ ssh "${opts[@]}" -o ConnectTimeout=2 -p "$SILENT_PORT" web1 uptime
Connection timed out during banner exchange
Connection to 127.0.0.1 port SILENT_PORT timed out
[exit status 255]
$ (( SECONDS - start < 4 )) && echo "gave up in under 4 seconds"
gave up in under 4 seconds
# 255 is ssh's own failure; any other status came from the remote command.
$ on() { ssh "${opts[@]}" "$@"; local rc=$?; if (( rc == 255 )); then echo "ssh failed, so no command ran" >&2; fi; return "$rc"; }
$ on -p "$CLOSED_PORT" web1 uptime
ssh: connect to host 127.0.0.1 port CLOSED_PORT: Connection refused
ssh failed, so no command ran
[exit status 255]
$ ls
ssh_config

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 ssh_fail_fast_session.py

web1 matched the web1 block first, so it got user deploy and connecttimeout 3, not the admin and 10 that appear later in the file, while serveraliveinterval 15 came from web* and batchmode yes applied to every host. web2 matched only the later blocks. The refused connection failed at once; the silent one ended with Connection timed out during banner exchange because -o ConnectTimeout=2 on the command line came before the file’s 3. Both ended with status 255, and ls showed that no known hosts file had been written.

The opts array keeps the session away from this computer’s own ssh setup. -F names the session’s file in place of the account’s own, and ssh(1) adds that the system-wide file is then ignored; the two known hosts options name a file of the session and /dev/null; and IdentityFile=none with IdentityAgent=none offers no key at all, since nothing here logs in. A real job names its one key instead, -i deploy_key -o IdentitiesOnly=yes, which ssh_config(5) says keeps ssh to the configured identity even when an agent offers others.

That 255 is the number to test. ssh(1) keeps 255 for its own errors and otherwise passes on the remote command’s status, so the on function could say “no command ran” with certainty. A remote command that itself exits 255 looks the same, which is one more reason to keep remote commands small and their statuses ordinary.

Give ssh its own standard input inside a loop

ssh forwards its standard input to the remote command, so in a loop that reads a list it can swallow the rest of the list. Use ssh -n (StdinNull in ssh_config(5)) or < /dev/null; the lesson on reading files line by line shows the failure and every fix. To reach many hosts a few at a time, use the job pool of background jobs and wait.

Checkpoint

Check: ssh options that fail fast, and status 255

  1. Question 1 of 2 A config file lists Host * with ConnectTimeout 10 first, and then Host web1 with ConnectTimeout 3. Which timeout does ssh web1 use?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 10 seconds, because the first value ssh finds for an option is the one it keeps

    The session's file put Host web1 first, so ssh -G web1 printed connecttimeout 3; Host * came last with 10. With the order reversed, the default would hide every host-specific setting. ssh -G shows the result without connecting.

  2. Question 2 of 2 ssh -o BatchMode=yes web3 systemctl is-active app ends with status 255. What can the script conclude?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: ssh itself failed, so the remote command most likely never ran

    Read 255 first: it means could not reach or log in to the host. Every other status is the remote command's own, 0 for success. The session's on function reported ssh failed, so no command ran for exactly this case.

scp, sftp and rsync: three ways to copy files over SSH

All three ride on an SSH connection, so the options of the last section apply to each of them, and each accepts -o options or an ssh command of its own. They differ in what they send and in what they can do besides sending.

scp copies the files you name, in full. Since OpenSSH 9.0 it uses the SFTP protocol by default, and the release notes say why that matters to a script: the old scp/rcp protocol expanded remote file names through the far end’s shell, so names with shell characters needed a second layer of quoting. scp -O still selects the old protocol for servers without SFTP, and scp(1) warns that it then needs careful quoting for that shell.

sftp -b batchfile runs a list of file commands without anyone at the keyboard, and sftp(1) says it aborts when get, put, rm, mkdir or a few other commands fail, so a script gets a status it can trust. It can also continue a partial file with reget, reput or -a.

rsync compares before it copies. It starts a second rsync on the far end through the remote shell, and rsync(1) says plainly that rsync must be installed on both machines. Answer the three questions of the table for your own copy:

Interactive · answer and compare Which tool should copy the files? Answer three questions about the copy your script makes. The table rules out the tools that do not fit and says why.
Can the far end run rsync, through an account with a shell that can start it?
Must a copy that is cut off half-way go on from where it stopped?
Should a file deleted at the source also disappear at the far end?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

scp, sftp in batch mode and rsync over ssh, against five things a copy in a script may need.
Tool Sends only what changed Resumes a broken copy Mirrors deletions Previews before writing Needs only SSH at the far end
scp Moves: whole files, named on the command line Poor sends every named file in full on every run Poor scp(1) has no option to resume; a cut copy starts again Poor never removes anything at the far end Poor no dry run: it writes as it goes Good uses the SFTP protocol by default since OpenSSH 9.0
sftp -b batchfile Moves: whole files, one batch command each Poor put and get move whole files Good reget and reput, or -a, continue a partial file Mixed only the names you list in rm lines of the batch file Poor no dry run Good talks to the SFTP server of sshd and nothing else
rsync over ssh Moves: only the parts that changed Good quick check, then the delta: 700 of 260,000 bytes in the run Good --partial keeps a cut file for the next run to finish Good --delete removes what the source no longer has Good -n with -i lists every change and writes nothing Poor rsync must be installed on both machines, and a shell must start it
How the answers decide
  • “Can the far end run rsync, through an account with a shell that can start it?” No, SFTP only or no rsync: rules out rsync over ssh. rsync starts a second rsync on the far end through the remote shell (rsync(1)), so an account limited to SFTP, or a host without rsync, rules it out.
  • “Must a copy that is cut off half-way go on from where it stopped?” Yes, files are large: rules out scp. scp has no way to continue a partial file; sftp can with reget, reput or -a, and rsync with --partial.
  • “Should a file deleted at the source also disappear at the far end?” Yes, keep an exact mirror: rules out scp and adds a caveat to sftp -b batchfile. scp never deletes at the far end, and sftp removes only the names a batch file lists, so only rsync with --delete keeps an exact mirror.

In short: rsync for anything a script copies again and again, sftp -b when the far account allows SFTP and nothing else, and scp for a one-off copy of a few files.

Checkpoint

Check: Choosing scp, sftp or rsync

  1. One question A partner gives your script an account that allows SFTP and nothing else, and large files must continue after a dropped link. Which tool fits?

    Choose one answer.

    Show the answer: One question

    Answer: sftp in batch mode, using reget or reput for files cut off half-way

    The far end decides first: no shell, no rsync. Among the SFTP tools, only sftp can resume, with reget, reput or -a, and its batch mode aborts on the first failed command, so the script sees the failure.

rsync’s quick check decides what to send, and the delta decides how much

The session copies a small site from site/ to live/ with -a (archive mode: recursive, keeping links, permissions and times) and -i, which prints one line per item that changes. Both folders are local, but the engine is the one that runs over ssh. On this Mac, /usr/bin/rsync is openrsync, as its first two lines say:

What rsync sends: itemized changes, a change the quick check misses, and the delta on a 260,000-byte log Python · rsync_delta_session.py
"""What rsync decides to send (the quick check) and how much of a file it sends (the delta).

rsync here is macOS's /usr/bin/rsync, which is openrsync (protocol 29); both copies are local folders, the same
engine that runs over ssh. rsync copies whole files between two local folders by default (rsync(1), --whole-file),
so the last runs add --no-W to make it use the delta-transfer algorithm, as it does between two computers, and -c so
the file is compared by its contents whatever its time says.
"""
from session import run, write

write("site/index.html", "<h1>Orders</h1>\n")
write("site/app.conf", "max_conn=100\n")
write("site/css/main.css", "body { margin: 0 }\n")

run(r"""
rsync --version | head -n 2
rsync -a -i site/ live/
rsync -a -i site/ live/
# A real change with the same size and the same time: the quick check sees nothing to send.
printf 'max_conn=900\n' > site/app.conf
touch -r live/app.conf site/app.conf
rsync -a -i site/ live/
cat live/app.conf
rsync -a -i -c site/ live/
cat live/app.conf
# A 260,000-byte log, copied once; then one byte changes in its middle.
mkdir logs
printf 'line %05d abcdefghijabcdefghijabcdefghijabcdefghij\n' $(seq 0 4999) > logs/app.log
wc -c < logs/app.log
rsync -a logs/ copy/
printf 'X' | dd of=logs/app.log bs=1 seek=130000 conv=notrunc 2>/dev/null
rsync -a -c --stats logs/ copy/ | grep -E '^(Matched|Unmatched) data'
printf 'Y' | dd of=logs/app.log bs=1 seek=200000 conv=notrunc 2>/dev/null
rsync -a -c --no-W --stats logs/ copy/ | grep -E '^(Matched|Unmatched) data'
cmp logs/app.log copy/app.log && echo identical
""")

Output

$ rsync --version | head -n 2
openrsync: protocol version 29
rsync version 2.6.9 compatible
$ rsync -a -i site/ live/
cd+++++++ ./
>f+++++++ app.conf
>f+++++++ index.html
cd+++++++ css/
>f+++++++ css/main.css
$ rsync -a -i site/ live/
# A real change with the same size and the same time: the quick check sees nothing to send.
$ printf 'max_conn=900\n' > site/app.conf
$ touch -r live/app.conf site/app.conf
$ rsync -a -i site/ live/
$ cat live/app.conf
max_conn=100
$ rsync -a -i -c site/ live/
>fc...... app.conf
$ cat live/app.conf
max_conn=900
# A 260,000-byte log, copied once; then one byte changes in its middle.
$ mkdir logs
$ printf 'line %05d abcdefghijabcdefghijabcdefghijabcdefghij\n' $(seq 0 4999) > logs/app.log
$ wc -c < logs/app.log
  260000
$ rsync -a logs/ copy/
$ printf 'X' | dd of=logs/app.log bs=1 seek=130000 conv=notrunc 2>/dev/null
$ rsync -a -c --stats logs/ copy/ | grep -E '^(Matched|Unmatched) data'
Unmatched data: 260000 B
Matched data: 0 B
$ printf 'Y' | dd of=logs/app.log bs=1 seek=200000 conv=notrunc 2>/dev/null
$ rsync -a -c --no-W --stats logs/ copy/ | grep -E '^(Matched|Unmatched) data'
Unmatched data: 700 B
Matched data: 259300 B
$ cmp logs/app.log copy/app.log && echo identical
identical

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rsync_delta_session.py

The first run created everything, so each line begins >f+++++++ (a file received, every attribute new) or cd+++++++ (a folder created). The second run printed nothing at all, because nothing differed. That silence comes from the quick check: rsync(1) says rsync looks for files that changed in size or in modification time, and skips the rest without reading them.

The next lines show where the quick check is blind. app.conf changed from max_conn=100 to max_conn=900, the same 13 bytes, and touch -r gave it the time of the copy already in live/. rsync sent nothing, and live/app.conf still said 100. With -c, rsync compared a checksum of every file whose size matched, found the difference, and printed >fc......, where c means the checksum differs. The price is that both sides read every byte of every file they compare, so -c is for files that tools rewrite without changing their size or time.

Once rsync has decided to send a file, the delta-transfer algorithm: How rsync updates a file the far end already has: the receiver sends checksums of the blocks of its copy, and the sender sends only the bytes that match none of them, plus block numbers for the rest. decides how much of it to send. The rsync technical report describes it: the receiver splits its old copy into blocks and sends a checksum of each, and the sender sends literal bytes only where no block matches. The session’s 260,000-byte log shows both modes. Between two local folders rsync copies whole files by default (rsync(1), --whole-file): there is no slow link to save, and working out the delta costs extra reading. So the first changed byte cost 260,000 unmatched bytes. With --no-W, as between two computers, one changed byte cost 700, and 259,300 bytes were matched blocks the receiver already had:

Interactive · message by message How rsync sent 700 bytes of a 260,000-byte file Step through the last copy of the session, the one with --no-W, and see which side sends what.
How rsync sent 700 bytes of a 260,000-byte file, step 5 of 5. Messages so far: Receiver to Sender: block checksums; Sender to Receiver: 700 bytes + block ids.Sender1 block unmatchedReceiversame as the senderblock checksums700 bytes + block ids

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Two copies that differ. Both files are 260,000 bytes long, and one byte near offset 200,000 differs. With -c the two sides compared whole-file checksums, so rsync knew the file had changed although its size had not.
  2. Checksums of the old blocks. The receiver splits its old copy into fixed-size blocks and sends two checksums for each one, a quick rolling one and a strong one. It sends checksums, never the blocks themselves.
  3. The sender looks for matches. The sender slides a window over its new file, one byte at a time, and looks up the rolling checksum at each position; a hit is confirmed with the strong one. Every block matched except the one that holds the changed byte.
  4. Only the change travels. The sender sends literal data for the part that matched nothing and a block number for everything else. openrsync counted 700 bytes unmatched and 259,300 matched: about 0.27 % of the file crossed.
  5. The receiver rebuilds it. The receiver writes a new copy from its own old blocks and the 700 new bytes, then moves it into place. cmp found the two files identical afterwards.

openrsync on this Mac, rsync 3.x from samba.org

This Mac’s /usr/bin/rsync is openrsync: its manual page says it speaks rsync protocols 27 to 29, and it printed rsync version 2.6.9 compatible. The samba.org rsync, version 3.x, prints itemized lines 11 letters wide, YXcstpoguax in rsync(1), against the 9 printed here, and has many more options. The options in this lesson exist in both; run rsync --version on both ends before you rely on anything newer.

How to mirror a folder with --delete, and why site/ and site copy different things, is in the lesson on archives, checksums and backups.

Checkpoint

Check: The quick check and the delta of rsync

  1. Question 1 of 2 A tool rewrites app.conf in place, keeping its size and its modification time. rsync -a -i site/ live/ prints nothing. What makes rsync copy the change?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Adding -c, so rsync compares a checksum of each file whose size matches

    rsync(1) calls it the quick check: files whose size and modification time match are skipped without being read. -c reads both copies of every such file, which is slower on a large tree but sees changes that keep the size and the time.

  2. Question 2 of 2 In the session, one byte of the 260,000-byte app.log changed, and rsync ran with -c --no-W. How many bytes did it report as unmatched data?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 700 bytes

    The delta sent literal data only for the block that held the changed byte: Unmatched data 700 B, Matched data 259,300 B. Without --no-W, the local copy sent the whole file, 260,000 unmatched bytes.

rsync over ssh needs rsync at both ends, and its status says what broke

Over the network, rsync reaches the far end with -e, which names the remote shell and its options as one string. rsync(1) says the string may carry arguments, separated by spaces, and that rsync splits it itself, so it is built from plain words here rather than from an array. The RSYNC_RSH variable does the same job for every rsync a script runs. The session points that ssh at a closed port on 127.0.0.1, then copies a missing folder and a real one:

rsync with -e: an ssh that cannot connect, a missing source, and a copy that works Python · rsync_ssh_session.py
"""rsync over ssh in a script: the -e command and the exit statuses a script must read.

rsync here is macOS's /usr/bin/rsync, which is openrsync (protocol 29). The ssh it starts gets the session's own
ssh_config and options, offers no key (IdentityFile=none, IdentityAgent=none) and only ever connects to 127.0.0.1, to
a port nothing listens on (shown as CLOSED_PORT), so nothing is sent anywhere. rsync's process number in its messages changes from run to run and is shown as PID.
"""
from session import env, mask, mask_re, run, write
import servers

closed_port = servers.closed()
env("CLOSED_PORT", closed_port)
mask(closed_port, "CLOSED_PORT")
mask_re(r"rsync\(\d+\)", "rsync(PID)")

write("ssh_config", "Host web1\n    HostName 127.0.0.1\n    User deploy\n\nHost *\n    BatchMode yes\n    ConnectTimeout 5\n")
write("site/index.html", "<h1>Orders</h1>\n")

run(r"""
# rsync splits the -e string at spaces itself, so it is one argument here, built from plain words.
rsh="ssh -F $PWD/ssh_config -o BatchMode=yes -o IdentitiesOnly=yes -o IdentityFile=none -o IdentityAgent=none -o UserKnownHostsFile=$PWD/known_hosts -o GlobalKnownHostsFile=/dev/null -p $CLOSED_PORT"
!rsync -a -e "$rsh" site/ web1:/srv/site/
# A source that is not there: nothing to send, and a status of its own.
!rsync -a reports/ backup/
rsync -a site/ backup/; echo "status $?"
""")

Output

# rsync splits the -e string at spaces itself, so it is one argument here, built from plain words.
$ rsh="ssh -F $PWD/ssh_config -o BatchMode=yes -o IdentitiesOnly=yes -o IdentityFile=none -o IdentityAgent=none -o UserKnownHostsFile=$PWD/known_hosts -o GlobalKnownHostsFile=/dev/null -p $CLOSED_PORT"
$ rsync -a -e "$rsh" site/ web1:/srv/site/
ssh: connect to host 127.0.0.1 port CLOSED_PORT: Connection refused
rsync(PID): error: unexpected end of file
[exit status 255]
# A source that is not there: nothing to send, and a status of its own.
$ rsync -a reports/ backup/
rsync(PID): error: reports/: (l)stat: No such file or directory
[exit status 23]
$ rsync -a site/ backup/; echo "status $?"
status 0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 rsync_ssh_session.py

When ssh could not connect, rsync printed ssh’s own message, then unexpected end of file, and ended with 255, the status ssh gives for its own failures. The missing source ended with 23, which rsync(1)’s EXIT VALUES table calls a partial transfer due to an error; 24 in the same table means source files vanished during the copy, which happens when a log is rotated mid-copy. One detail is worth remembering: openrsync’s own manual page lists only 0, 1 and 2, yet it returned 23 and 255 here. Treat every status but 0 as a failed copy, and log the number.

Three more options belong in a script that copies over a network. -z compresses file data as it travels, which helps on a slow link but costs processor time at both ends, so it earns its place only when the link is the slow part; data that is already compressed, such as a .gz or .jpg file, barely shrinks, so the work is wasted on it. --partial keeps a partly sent file when the transfer is cut, which rsync(1) says makes the next transfer of the rest of that file much faster. And -n with -i (a dry run) prints the same itemized lines a real run would, without changing anything, which rsync(1) says is the point of a dry run: read it before any run that deletes.

Checkpoint

Check: rsync over ssh and its exit statuses

  1. One question rsync -a -e "$rsh" site/ web1:/srv/site/ prints an ssh error, then unexpected end of file, and ends with 255. What happened?

    Choose one answer.

    Show the answer: One question

    Answer: ssh could not connect, so rsync never reached a second rsync and copied nothing

    Read rsync's first error line: an ssh message points at the connection, a file name points at the source. Treat every status but 0 as a failed copy; the session gave 255, 23 and 0, and openrsync returned codes its own manual page does not list.

curl in a script must be told that an HTTP error is a failure

Every HTTP answer starts with an HTTP status code: The three-digit number at the start of every HTTP answer. Its first digit gives the class: 1xx informational, 2xx success, 3xx redirection, 4xx an error in the request, 5xx an error at the server., and RFC 9110 sorts them by their first digit: 1xx informational, 2xx success, 3xx redirection, 4xx an error in the request, 5xx an error at the server. To curl, though, a 404 page that arrived intact is a successful transfer. The session runs it against a small HTTP server on 127.0.0.1, which starts with the example and stops with it (its port prints as WEB_PORT, and the milliseconds curl reports as N):

A 404 without and with -f, a redirect, a stalled answer, a refused connection and a retry Python · curl_failures_session.py
"""How curl reports what went wrong: HTTP errors, a redirect, a stalled answer, a refused connection, and a retry.

curl is macOS's /usr/bin/curl 8.7.1. It talks only to servers.web(), a small HTTP server that this example starts on
127.0.0.1 and stops when it ends; API holds its address. The port the system chose and the milliseconds curl reports
change from run to run, so they are shown as WEB_PORT, CLOSED_PORT and N.
"""
from session import env, mask, mask_re, run
import servers

ok = '{"status":"ok","version":"2.4.1"}\n'
web_port = servers.web({
    "/health": [(200, ok)],
    "/latest": [(302, "", "text/plain", 0, {"Location": "/reports/v2.json"})],
    "/reports/v2.json": [(200, '{"report":"v2","rows":3}\n')],
    "/slow": [(200, ok, "application/json", 3)],
    "/busy": [(503, '{"error":"warming up"}\n'), (503, '{"error":"warming up"}\n'), (200, ok)],
})
closed_port = servers.closed()
env("API", f"http://127.0.0.1:{web_port}")
env("CLOSED_PORT", closed_port)
mask(web_port, "WEB_PORT")
mask(closed_port, "CLOSED_PORT")
mask_re(r"after \d+ milliseconds", "after N milliseconds")
mask_re(r"after \d+ ms", "after N ms")

run(r"""
curl -sS "$API/reports/q3"; echo "curl status: $?"
!curl -fsS "$API/reports/q3"
!curl -sS --fail-with-body "$API/reports/q3"
curl -sS -o /dev/null -w '%{http_code}\n' "$API/reports/q3"
# A redirect is not an error: without -L, curl saves the empty redirect answer and succeeds.
curl -fsS -o latest.json "$API/latest"; echo "status $?"
test -s latest.json || echo "latest.json is empty"
curl -fsSL -o latest.json "$API/latest"; cat latest.json
!curl -fsS --max-time 1 "$API/slow"
!curl -fsS "http://127.0.0.1:$CLOSED_PORT/health"
curl -fsS --retry 3 --retry-delay 1 "$API/busy"
""")

Output

$ curl -sS "$API/reports/q3"; echo "curl status: $?"
{"error":"no such path"}
curl status: 0
$ curl -fsS "$API/reports/q3"
curl: (22) The requested URL returned error: 404
[exit status 22]
$ curl -sS --fail-with-body "$API/reports/q3"
curl: (22) The requested URL returned error: 404
{"error":"no such path"}
[exit status 22]
$ curl -sS -o /dev/null -w '%{http_code}\n' "$API/reports/q3"
404
# A redirect is not an error: without -L, curl saves the empty redirect answer and succeeds.
$ curl -fsS -o latest.json "$API/latest"; echo "status $?"
status 0
$ test -s latest.json || echo "latest.json is empty"
latest.json is empty
$ curl -fsSL -o latest.json "$API/latest"; cat latest.json
{"report":"v2","rows":3}
$ curl -fsS --max-time 1 "$API/slow"
curl: (28) Operation timed out after N milliseconds with 0 bytes received
[exit status 28]
$ curl -fsS "http://127.0.0.1:$CLOSED_PORT/health"
curl: (7) Failed to connect to 127.0.0.1 port CLOSED_PORT after N ms: Couldn't connect to server
[exit status 7]
$ curl -fsS --retry 3 --retry-delay 1 "$API/busy"
curl: (22) The requested URL returned error: 503
curl: (22) The requested URL returned error: 503
{"status":"ok","version":"2.4.1"}

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 curl_failures_session.py

Without -f, curl printed the server’s error body and exited 0, so a script would carry on with an error message as its data. With -f it printed curl: (22) The requested URL returned error: 404 and exited 22; --fail-with-body, new in curl 7.76.0, kept the body for the log and still exited 22. -s hides the progress meter and the errors, and -S brings the errors back, which is why scripts write -fsS. When the code itself matters, -w '%{http_code}' prints it, here 404.

A redirect is not an error either. Without -L, curl saved the empty answer of the 302 into latest.json and exited 0; with -L it followed the Location and saved the report. Step down the stages of one call to see which status each failure leaves:

Interactive · step by step Where a curl call can fail, and the status it leaves Step down the stages of one call. Each step names the status a script sees when that stage fails.
Where a curl call can fail, and the status it leaves, step 5 of 5. Showing Look up the host, Connect, Get the answer, Read the code, Check the body, Status 6, Status 7, Status 28, Status 22, jq exits 1. In focus: Check the body, jq exits 1.Look up the hostname to addressConnectTCP to the portGet the answerwithin --max-timeRead the code200, 404, 503 …Check the bodyjq -e on the JSONStatus 6host not foundStatus 7connect failedStatus 28timed outStatus 22code 400+, with -fjq exits 1false or null

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. Find the address. curl turns the host name into an address first. When no name server knows the name, it stops with status 6. The session used 127.0.0.1, an address already, so it never hit this one.
  2. Open the connection. Next it opens a TCP connection to the port. Nothing listened on CLOSED_PORT, so curl stopped at once with status 7, "Failed to connect".
  3. Wait, but not forever. A server can accept the connection and then take its time. --max-time 1 gave up on the 3-second answer of /slow with status 28; --connect-timeout limits only the connection itself.
  4. An error page is a success. An answer of 404 or 503 is a transfer that worked, as far as curl knows: without -f it printed the error body and exited 0. With -f or --fail-with-body, a status code of 400 or more becomes exit status 22.
  5. Status 0 is not the end. Status 0 only says an answer arrived. jq -e checks what it says: false or null makes jq exit 1, and with pipefail set the whole pipeline fails with it.

The last three lines of the session are the stalls. --max-time 1 ended the 3-second answer with status 28, and the closed port gave 7 at once; --connect-timeout would cap only the connection, and a name that no server can resolve gives 6. --retry 3 --retry-delay 1 met two 503 answers, printed both, and then got the 200: status 0. The curl 8.7.1 manual counts a timeout and the codes 408, 429, 500, 502, 503 and 504 as transient, waits 1 second before the first retry and doubles the wait after that unless --retry-delay fixes it, and stops retrying when --retry-max-time runs out. A policy of your own, with a deadline for the whole job, is a loop: see while, until and loop control. To turn a call you have tested by hand into code, the curl to code converter reads the same options.

Key idea

Give every network call in a script a way to fail and a time to give up: BatchMode and ConnectTimeout for ssh, -fsS and --max-time for curl, and then read the status, because 255, 22, 28 and 7 each name a different fault.

Checkpoint

Check: curl's statuses for HTTP errors, redirects and stalls

  1. Question 1 of 2 curl -sS "$API/reports/q3" > report.json; echo $? prints 0, yet report.json holds an error message. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Without -f, curl treats a 404 that arrived intact as a successful transfer

    An HTTP error is data to curl until -f, or --fail-with-body when the body should stay for the log, turns it into exit status 22. Scripts write -fsS: fail on HTTP errors, hide the progress meter, show the errors.

  2. Question 2 of 2 A server accepts the connection and answers after 3 seconds. With what exit status does curl -fsS --max-time 1 "$API/slow" end?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 28

    --max-time caps the whole transfer, and when it runs out curl reports Operation timed out, status 28, as the session showed for /slow. A refused connection gives 7 instead, and an unknown host name 6.

A JSON answer needs its own check, and a token belongs in a file

Status 0 from curl -f says an answer arrived with a code below 400. It says nothing about what the answer says. The jq manual gives -e (--exit-status) the job: jq exits 0 when the last value it printed is neither false nor null, 1 when it is one of them, and 4 when it produced nothing at all. Put the two in a pipeline with pipefail: Bash's set -o pipefail option: the exit status of a pipeline is the rightmost non-zero stage's status, or 0 when every stage succeeded. A stage killed by SIGPIPE (because its reader closed early) is reported as 128 + 13 = 141. More in “set -e, set -u and pipefail: what they really do” set, as the lesson on errexit, nounset and pipefail explains, and the pipeline fails when either command does:

jq -e on a healthy and a degraded answer and a missing field, and a token sent from a mode-600 file Python · curl_json_session.py
"""Checking a JSON answer with jq -e, and sending a token that sits in a mode-600 file instead of on the command line.

curl is macOS's /usr/bin/curl 8.7.1 and jq is 1.8.2. Both talk only to servers.web(), which this example starts on
127.0.0.1 and stops when it ends (API holds its address, shown as WEB_PORT). The token is a made-up placeholder that
only this little server knows.
"""
from session import env, mask, run, write
import servers

TOKEN = "EXAMPLE-NOT-A-SECRET"


def whoami(headers):
    if headers.get("Authorization") == f"Bearer {TOKEN}":
        return 200, '{"user":"deploy-bot","scopes":["read"]}\n'
    return 401, '{"error":"missing or wrong token"}\n'


web_port = servers.web({
    "/health": [(200, '{"status":"ok","version":"2.4.1","queue":3}\n')],
    "/health-b": [(200, '{"status":"degraded","version":"2.4.1","queue":4100}\n')],
    "/whoami": [(0, whoami)],
})
env("API", f"http://127.0.0.1:{web_port}")
mask(web_port, "WEB_PORT")
write("auth.header", f"Authorization: Bearer {TOKEN}\n")

run(r"""
set -o pipefail
curl -fsS "$API/health" | jq -e '.status == "ok"'
!curl -fsS "$API/health-b" | jq -e '.status == "ok"'
!curl -fsS "$API/health" | jq -e '.disk_free_gb'
version=$(curl -fsS "$API/health" | jq -er '.version') && echo "running $version"
# The header line lives in a file only its owner can read; curl reads the file itself.
chmod 600 auth.header
ls -l auth.header | cut -c1-10
curl -fsS -H @auth.header "$API/whoami"
!curl -fsS "$API/whoami"
""")

Output

$ set -o pipefail
$ curl -fsS "$API/health" | jq -e '.status == "ok"'
true
$ curl -fsS "$API/health-b" | jq -e '.status == "ok"'
false
[exit status 1]
$ curl -fsS "$API/health" | jq -e '.disk_free_gb'
null
[exit status 1]
$ version=$(curl -fsS "$API/health" | jq -er '.version') && echo "running $version"
running 2.4.1
# The header line lives in a file only its owner can read; curl reads the file itself.
$ chmod 600 auth.header
$ ls -l auth.header | cut -c1-10
-rw-------
$ curl -fsS -H @auth.header "$API/whoami"
{"user":"deploy-bot","scopes":["read"]}
$ curl -fsS "$API/whoami"
curl: (22) The requested URL returned error: 401
[exit status 22]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 curl_json_session.py

.status == "ok" printed true for the healthy server and false, with status 1, for the degraded one, although curl succeeded both times. A field that is missing, .disk_free_gb, printed null and also failed, which is what a script wants from a typo or an API change. jq -er '.version' printed the raw value, so version=$(…) held 2.4.1, and the && ran only because the whole pipeline succeeded. More filters are in the lesson on jq, and the JSON formatter makes a long answer readable.

A token on curl’s command line is in its argument list, where other users of the same computer can read it, as the lesson on secure scripting shows. curl reads headers from a file instead: -H @auth.header sent the Authorization line from a file of mode 600, -rw-------, readable by its owner alone, and the server answered as deploy-bot; without the file it answered 401, and -f turned that into status 22. -K reads a whole set of options from a file in the same way. Keep the file in a secret store: Where a secret value lives at rest: a mode-600 file on the host, a dedicated store such as GitHub Actions secrets or HashiCorp Vault, or a password manager. A script reads the value from the store at each run and does not keep a copy of it. More in “Secure scripting: temp files, secrets and PATH” or write it with umask 077, and remove it when the job ends.

A header goes wherever -L goes

The curl manual warns that headers given with -H are sent in every request, including the requests that -L makes after a redirect, which can take a sensitive header to another host. A call that carries a token either leaves out -L or checks where the redirect leads first.

Checkpoint

Check: Checking JSON with jq, and tokens kept in files

  1. One question With set -o pipefail, the health check curl -fsS "$API/health" | jq -e '.status == "ok"' meets an answer whose status is "degraded". What is the pipeline's status?

    Choose one answer.

    Show the answer: One question

    Answer: 1, because jq printed false and -e turns false into status 1

    curl -f checks the code and jq -e checks the content: false or null gives 1, no result 4. pipefail makes the pipeline report the failure even though the last command is not the only one that matters.

Bash can test a TCP port by itself through /dev/tcp

Before a script calls a service, it can ask whether anything is listening. The Bash manual lists /dev/tcp redirection: A redirection to the name /dev/tcp/host/port, which Bash recognises and turns into a TCP connection to that host and port. No such file exists on the disk, and dash and POSIX sh do not have the feature. among the names Bash treats specially in a redirection: when the host and port are valid, Bash opens a TCP connection to them. Nothing on the disk has that name, and the session’s last line shows it: dash, which has no such feature, tried to create the file, and macOS has no /dev/tcp folder. Bash itself has the feature only when it was built with it, through the --enable-net-redirections option, on by default.

port_open with /dev/tcp and timeout, a raw HTTP request, and dash Python · port_check_session.py
"""A TCP port check with nothing but Bash: the /dev/tcp/host/port redirection, wrapped in timeout.

Bash opens the connection itself (the Bash manual, Redirections); dash has no such name and macOS has no /dev/tcp
folder. The only ports involved are on 127.0.0.1: a small HTTP server this example starts and stops (WEB_PORT) and a
port nothing listens on (CLOSED_PORT). timeout is GNU coreutils 9.12.
"""
from session import env, mask, run
import servers

web_port = servers.web({"/health": [(200, '{"status":"ok"}\n')]})
closed_port = servers.closed()
env("WEB_PORT", web_port)
env("CLOSED_PORT", closed_port)
mask(web_port, "WEB_PORT")
mask(closed_port, "CLOSED_PORT")

run(r"""
port_open() { timeout 3 bash -c ': > "/dev/tcp/$1/$2"' port_open "$1" "$2"; }
port_open 127.0.0.1 "$WEB_PORT" && echo "WEB_PORT is open"
!port_open 127.0.0.1 "$CLOSED_PORT"
port_open 127.0.0.1 "$CLOSED_PORT" 2>/dev/null || echo "CLOSED_PORT: status $?"
# The same name opens a two-way descriptor: an HTTP request typed by hand.
exec 3<>"/dev/tcp/127.0.0.1/$WEB_PORT"
printf 'GET /health HTTP/1.0\r\n\r\n' >&3
head -n 1 <&3
exec 3<&-
!dash -c ': > /dev/tcp/127.0.0.1/80'
""")

Output

$ port_open() { timeout 3 bash -c ': > "/dev/tcp/$1/$2"' port_open "$1" "$2"; }
$ port_open 127.0.0.1 "$WEB_PORT" && echo "WEB_PORT is open"
WEB_PORT is open
$ port_open 127.0.0.1 "$CLOSED_PORT"
port_open: connect: Connection refused
port_open: line 1: /dev/tcp/127.0.0.1/CLOSED_PORT: Connection refused
[exit status 1]
$ port_open 127.0.0.1 "$CLOSED_PORT" 2>/dev/null || echo "CLOSED_PORT: status $?"
CLOSED_PORT: status 1
# The same name opens a two-way descriptor: an HTTP request typed by hand.
$ exec 3<>"/dev/tcp/127.0.0.1/$WEB_PORT"
$ printf 'GET /health HTTP/1.0\r\n\r\n' >&3
$ head -n 1 <&3
HTTP/1.1 200 OK
$ exec 3<&-
$ dash -c ': > /dev/tcp/127.0.0.1/80'
dash: 1: cannot create /dev/tcp/127.0.0.1/80: Directory nonexistent
[exit status 2]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 port_check_session.py

port_open passes the host and port to an inner bash -c as $1 and $2, never as text inside its script, and opens the connection with the null command :. The server’s port answered at once; the closed port printed two Connection refused lines and returned 1. The same name opens a two-way file descriptor: A small number a running program uses to name one of its open files or streams. A program normally starts with 0 (standard input), 1 (standard output) and 2 (standard error) already open. More in “Redirection: stdin, stdout, stderr, /dev/null”: exec 3<> connected descriptor 3, printf sent an HTTP request by hand, and head -n 1 read HTTP/1.1 200 OK, the status line whose number -w '%{http_code}' prints. The port number lookup says which service a port number usually belongs to.

The timeout 3 around it is for the case the session cannot show on one computer: a firewall that drops packets gives no refusal at all, so a connection attempt waits for the system’s own TCP timeout. GNU’s timeout manual says timeout then ends the command and exits with status 124, a number a script can test like any other; the lesson on signals, kill and timeout covers it in full.

Checkpoint

Check: Port checks with /dev/tcp and timeout

  1. One question timeout 3 bash -c ': > /dev/tcp/10.0.0.5/5432' ends with status 124 instead of 1. What does that say about the port?

    Choose one answer.

    Show the answer: One question

    Answer: The connection attempt got no answer at all within 3 seconds, as when a firewall drops the packets

    Three outcomes, three statuses: 0 when something accepts, 1 when the host refuses, 124 when timeout ends a silent wait. A check that reports them apart tells a firewall problem from a stopped service.

Practise: what each remote command leaves behind

These questions change one detail of the sessions at a time: a value with a quote in it, a host built minutes ago, a far end without rsync, a redirect saved as an empty file, a 503 that clears on the third try, a field missing from the JSON. Work out the status and the output first; each explanation then names the recorded run behind its answer:

Practice that adapts to you

Practise this lesson

20 questions about 8 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 20 How many shells parse the words of ssh host command arg before the command runs?

    Choose one answer.

    Show the answer: Question 1 of 20

    Answer: Two: your shell, then the far account's login shell run by sshd with -c

    Count the parses, not the programs: your shell splits and expands the line, ssh joins the words, and the far shell splits and expands the string again. Quoting must survive both parses.

  2. Question 2 of 20 A value read from a form, g='q3.csv; touch MARKER', reaches ssh web1 wc -l "$g". What runs on the far end?

    Choose one answer.

    Show the answer: Question 2 of 20

    Answer: wc -l q3.csv, then touch MARKER as a second command

    This is command injection through the second parse. Quoting the value with printf %q made the session pass it as one name that wc could not open, and no MARKER appeared.

  3. Question 3 of 20 A file is called it's.csv. Which line counts its lines on web1 correctly?

    Choose one answer.

    Show the answer: Question 3 of 20

    Answer: printf -v q '%q' "$f"; ssh web1 "wc -l $q"

    Hand-written quotes break on the first value that holds the same quote; printf %q has no such hole. For anything longer than one command, send a script with bash -s and quote its arguments the same way.

  4. Question 4 of 20 What does BatchMode yes change for ssh in a script?

    Choose one answer.

    Show the answer: Question 4 of 20

    Answer: ssh asks nothing: no password, passphrase or host-key question, and fails instead

    A prompt with nobody to answer is a hang. With BatchMode the same situation becomes status 255 and a message the log keeps.

  5. Question 5 of 20 A script sets ServerAliveInterval 20 and leaves ServerAliveCountMax at its default. About how many seconds after a host stops answering does ssh disconnect?

    Type a number.

    Show the answer: Question 5 of 20

    Answer: 60 seconds

    The default count is 3 (ssh_config(5)), and each unanswered request comes 20 seconds after the last data, so ssh gives up after about 3 x 20 = 60 seconds; the manual's own example, 15 and 3, gives about 45.

  6. Question 6 of 20 A provisioning script connects to hosts built minutes ago, whose keys no file holds yet, and must still refuse any host whose key has changed. Which StrictHostKeyChecking value does that?

    Choose one answer.

    Show the answer: Question 6 of 20

    Answer: accept-new

    yes is the strictest and needs every key shipped beforehand; accept-new trusts a host on first sight and then holds it to that key. Neither ever accepts a changed key.

  7. Question 7 of 20 Which protocol does scp use for its transfers by default since OpenSSH 9.0?

    Choose one answer.

    Show the answer: Question 7 of 20

    Answer: SFTP, over the ssh connection

    With SFTP, remote file names no longer pass through the far end's shell, so they need no second layer of quoting. scp still sends whole files and cannot resume.

  8. Question 8 of 20 rsync over ssh to a new host fails, and the far end reports that it has no command called rsync. Why does that stop the copy?

    Choose one answer.

    Show the answer: Question 8 of 20

    Answer: rsync starts a second rsync on the far end, so it must be installed on both machines

    The comparing and the delta happen between two rsync processes. Install rsync on the far end, name it with --rsync-path when it lives elsewhere, or copy with sftp.

  9. Question 9 of 20 What does the itemized line >fc...... app.conf from rsync -i -c say?

    Choose one answer.

    Show the answer: Question 9 of 20

    Answer: A regular file is sent because its checksum differs; its size and time are unchanged

    The first letter says what happens (> a file transferred to this side, c a local change such as a new folder, * a message such as deleting), the second what the item is (f file, d folder), and the rest which attributes changed: c checksum, s size, t time, p permissions.

  10. Question 10 of 20 A local copy with rsync -a -c --stats reports Unmatched data: 260000 B after one byte of a 260,000-byte file changed. Why so much?

    Choose one answer.

    Show the answer: Question 10 of 20

    Answer: Between two local paths rsync copies whole files by default, without the delta

    The quick check, or -c, decides whether a file goes; the delta decides how much of it travels. On a local copy the delta saves no network, so rsync skips it unless --no-W asks for it.

  11. Question 11 of 20 A nightly job syncs a 2 TB archive in which files are never rewritten in place. A teammate adds -c to every run, and the job now takes hours longer. What do you say?

    Choose one answer.

    Show the answer: Question 11 of 20

    Answer: Drop -c: it reads every byte on both sides, and these files never change with the same size and time

    Use -c where tools rewrite files with the same size and time, as the session's app.conf; elsewhere the quick check gets the same answer for the price of a directory listing.

  12. Question 12 of 20 What does rsync's exit status 23 mean?

    Choose one answer.

    Show the answer: Question 12 of 20

    Answer: A partial transfer due to an error, such as a source that is missing

    The EXIT VALUES table of rsync(1) runs from 0 to 35. openrsync's own page lists only 0, 1 and 2, yet it returned 23 and 255 in the sessions, so log the number and look it up.

  13. Question 13 of 20 Why does the session pass ssh and its options to rsync as one string, -e "$rsh", and not as an array?

    Choose one answer.

    Show the answer: Question 13 of 20

    Answer: rsync takes the remote shell as one argument and splits it at spaces itself

    Build the string from plain words, or set RSYNC_RSH for every rsync of the script. A path with a space inside it would need quotes that rsync itself understands.

  14. Question 14 of 20 curl -fsS -o latest.json "$API/latest" exits 0, yet latest.json is empty. The server answered 302. What fixes it?

    Choose one answer.

    Show the answer: Question 14 of 20

    Answer: Add -L so curl follows the Location of the redirect

    RFC 9110 puts 3xx codes in the redirection class: the answer points elsewhere. curl follows only when told to, and with a token in a header, check where the redirect leads before adding -L.

  15. Question 15 of 20 A server answers 503 twice and then 200. What does a script see from curl -fsS --retry 3 --retry-delay 1 "$API/busy"?

    Choose one answer.

    Show the answer: Question 15 of 20

    Answer: Two (22) error lines for the 503s, then the body of the 200, and status 0

    --retry repeats a transfer that failed for a transient reason: a timeout or the codes 408, 429, 500, 502, 503 and 504 in curl 8.7.1. --retry-max-time caps the time spent on retries in all.

  16. Question 16 of 20 curl ends with status 7 in one run and 28 in another. What is the difference?

    Choose one answer.

    Show the answer: Question 16 of 20

    Answer: 7: the connection failed, as on a closed port; 28: a time limit ran out

    A refused connection fails at once; a server that accepts and then stalls is caught only by a limit such as --max-time or --connect-timeout, and then the status is 28.

  17. Question 17 of 20 What exit status does jq -e '.disk_free_gb' give when the field does not exist in the answer?

    Choose one answer.

    Show the answer: Question 17 of 20

    Answer: 1, because jq prints null and -e treats null as a failure

    With -e, a typo in a filter or a renamed field fails the script instead of passing null on as data.

  18. Question 18 of 20 Which of these keep an API token off curl's command line?

    Choose every answer that is right.

    Show the answer: Question 18 of 20

    Answer:

    • curl -K api.conf, with a header line in the config file
    • curl -H @auth.header, with the header line in a mode-600 file

    What matters is where the value is when curl starts: in a file curl opens, or in its arguments. Keep the file at mode 600 and remove it when the job ends.

  19. Question 19 of 20 A #!/bin/sh script on a host where sh is dash runs : > /dev/tcp/127.0.0.1/80. What happens?

    Choose one answer.

    Show the answer: Question 19 of 20

    Answer: dash treats it as a file name and fails, because only Bash gives /dev/tcp a meaning

    A port check with /dev/tcp needs a Bash script, started as bash and not as sh; the same check in POSIX sh needs a tool such as nc or curl.

  20. Question 20 of 20 In the session, port_open 127.0.0.1 "$CLOSED_PORT" returned 1. Which status would it return for a port where a server is listening?

    Choose one answer.

    Show the answer: Question 20 of 20

    Answer: 0, because the redirection succeeds and : does nothing

    0 for open, 1 for refused, 124 for no answer within the limit. The same name with exec 3<> also lets Bash send bytes, as the hand-typed HTTP request showed with HTTP/1.1 200 OK.

Interview questions

Warm-up (fresher to 2–5 years): after ssh host command returns, which side failed, and how can a script tell?

The exit status says which side failed. Per the EXIT STATUS section of ssh(1), ssh hands back the remote command’s own status, and keeps 255 for errors of its own. So 0 means the command ran and succeeded, 1 to 254 mean it ran and failed with that status, and 255 means ssh itself failed: no connection, a refused key, a changed host key, a timeout. A script therefore tests for 255 first and reports “could not reach the host” apart from “the command failed”. With BatchMode=yes and ConnectTimeout, that 255 comes within seconds instead of after a prompt that nobody answers. One caveat: a remote command that itself exits 255 cannot be told apart, so keep remote commands to ordinary statuses.

Three harder questions come next, each from a script that reached another machine and did the wrong thing there: a “fix” for a host-key error in a pipeline, a mirror that emptied a live folder, and a download that saved the wrong kind of answer. Sketch how you would begin each one, then open its first step:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A nightly deploy job starts failing on one host with "REMOTE HOST IDENTIFICATION HAS CHANGED" the day after that host was rebuilt. A teammate adds -o StrictHostKeyChecking=no to the job, and it goes green again. What does that option really do here, and how would you fix the job instead?

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A deploy step runs rsync -a --delete build/ web1:/srv/site/ right after the build. One night the build stopped early without failing the job, build/ was left empty, and the live site went blank. Explain what rsync did, and redesign the step so that an empty or half-made build can never wipe the site.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A nightly job runs curl -fsSL -o rates.json "$URL" and then loads the file. During the provider's maintenance, curl exits 0, and the loader crashes on a file that begins with <html>, after yesterday's good file was already overwritten. Why did -f not stop it, and how would you rebuild the download step?

    Model answer, Pro. First step and follow-ups

Flashcards · 8 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. How does ssh pass the words after the host to the remote side?

    It joins them into one string, and the remote login shell parses it again. Quote each value with printf %q, or send the script on standard input with bash -s, whose arguments still need quoting.

  2. Which ssh options make a script fail fast instead of hanging?

    BatchMode=yes, a ConnectTimeout and server-alive messages, so a missing key or a dead host fails in seconds. The first value set in ssh_config wins.

  3. After ssh host command returns, how does a script tell which side failed?

    Status 255 means ssh itself failed, for example no connection or a refused key. Any other status is the remote command's own.

  4. How do scp, sftp and rsync differ for copying files over SSH?

    scp copies whole files and uses SFTP since OpenSSH 9.0; sftp -b runs a checked batch and can resume; rsync compares first and sends only what changed, which needs rsync on both machines.

  5. How does rsync decide which files to send, and how much of each?

    Its quick check skips files whose size and time match, which -c replaces with full checksums at the cost of reading everything. The delta then sends only the changed parts: in the lesson, 700 of 260,000 bytes for one changed byte.

  6. Why does curl exit 0 on a 404, and how does a script make it fail?

    curl counts any HTTP answer as a success unless told otherwise: -f makes an HTTP error exit 22 and -L follows redirects. Exit 28 means the transfer timed out and 7 a refused connection.

  7. How should a script pass an API token to curl and check the JSON answer?

    Keep the token off the command line with -H @file, and check the answer with jq -e under pipefail, so a wrong or empty answer fails the step.

  8. How can Bash test a TCP port without any other tool?

    Redirect to /dev/tcp/host/port, which Bash opens as a connection, under timeout. dash and POSIX sh have no /dev/tcp.

Key takeaways

  • ssh joins the words after the host into one string, and the far end’s login shell parses it again, so quote each value with printf %q or send the script on standard input with bash -s, whose arguments still need the quoting.
  • A script’s ssh needs BatchMode=yes, a ConnectTimeout and server-alive messages so it fails in seconds; the first value in ssh_config wins, and status 255 means ssh failed, while any other status is the remote command’s.
  • scp copies whole files and uses SFTP since OpenSSH 9.0, sftp -b runs checked batches and can resume, and rsync compares first, which needs rsync on both machines.
  • rsync’s quick check skips files whose size and time match, which -c corrects at the cost of reading everything, and its delta sent 700 of 260,000 bytes for one changed byte; over ssh it reports 255, 23 or 24 when a copy fails.
  • curl exits 0 on a 404 or a 302 unless -f and -L say otherwise, and 22, 28 and 7 name an HTTP error, a stall and a refused connection; jq -e with pipefail rejects a wrong answer, and -H @file keeps a token off the command line.
  • /dev/tcp/host/port lets Bash open a TCP connection in a redirection, which makes a port check with timeout that needs no other tool, and which dash and POSIX sh do not have.

Terms in this lesson

/dev/tcp redirection
A redirection to the name /dev/tcp/host/port, which Bash recognises and turns into a TCP connection to that host and port. No such file exists on the disk, and dash and POSIX sh do not have the feature.
batch mode
The ssh option BatchMode yes, which turns off every question ssh would ask a person (a password, a passphrase, whether to trust a new host key), so a script gets an error and status 255 instead of a prompt.
delta-transfer algorithm
How rsync updates a file the far end already has: the receiver sends checksums of the blocks of its copy, and the sender sends only the bytes that match none of them, plus block numbers for the rest.
HTTP status code
The three-digit number at the start of every HTTP answer. Its first digit gives the class: 1xx informational, 2xx success, 3xx redirection, 4xx an error in the request, 5xx an error at the server.
known hosts file
A file of host names and the public host keys ssh expects from them, ~/.ssh/known_hosts by default. ssh checks each server's key against it and refuses to connect, or warns, when the key differs.
public-key authentication
Logging in by proving you hold a private key whose public half the server lists for the account, in ~/.ssh/authorized_keys for OpenSSH. No password travels, so a script can log in without a prompt.
remote command
A command line given to ssh after the host name. ssh joins its words with spaces and sends one string, which the far account's login shell parses again before running it, so its quoting must survive two shells.

References

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.