Guide contents

Lesson 11 of 14

SSH, scp, and rsync

SSH opens an encrypted connection to another machine and runs commands there. scp and rsync use the same connection to copy files. This lesson covers key-based login, remote commands, host aliases, the agent, port forwarding, and two ways to copy files, with real output for each.

Command Purpose
ssh-keygen Create a key pair and print key fingerprints
~/.ssh/authorized_keys File on the server that lists the public keys allowed to log in
ssh user@host Log in, or run one command on the remote machine
~/.ssh/config Give hosts short names and per-host settings
ssh-agent, ssh-add Hold decrypted keys in memory so a passphrase is typed once
ssh -L Forward a local port to a port reachable from the remote machine
scp Copy files and directories over SSH
rsync -avz Copy only what changed, keep metadata, compress in transit
rsync --dry-run, --delete Preview a copy, and make the destination match the source

How the examples were run

SSH needs a server. The examples start a private sshd on 127.0.0.1 port 2222, so "the remote machine" is the same computer. A reader repeats them with a real server by replacing 127.0.0.1, the port, and the user name.

The setup used a scratch home directory, shown below as /home/dev, and a throwaway host key and configuration. The server was started as root and stopped at the end.

Terminal
mkdir -p ~/sshd-demo
ssh-keygen -q -t ed25519 -N "" -f ~/sshd-demo/host_key
cat > ~/sshd-demo/sshd_config <<CFG
Port 2222
ListenAddress 127.0.0.1
HostKey $HOME/sshd-demo/host_key
PidFile $HOME/sshd-demo/sshd.pid
PasswordAuthentication no
UsePAM no
Subsystem sftp internal-sftp
CFG
sudo mkdir -p /run/sshd
sudo /usr/sbin/sshd -f ~/sshd-demo/sshd_config

Because the server and the client share one file system, remote paths in the examples are absolute (/home/dev/demo/...). On a real server, a relative remote path such as backup/ is resolved from the remote home directory.

Output that changes on every run: key fingerprints, randomart images, the encoded key text, and file times. The text says where this matters.

Example 1: Generate a key pair with ssh-keygen

A key pair has a private key that stays on the client and a public key that is placed on servers. Logging in with the pair needs no password to cross the network.

Terminal
ssh-keygen -t ed25519 -C "demo@example" -f ~/.ssh/id_ed25519_demo -N ""
ls -l ~/.ssh
cat ~/.ssh/id_ed25519_demo.pub
ssh-keygen -l -f ~/.ssh/id_ed25519_demo.pub
Output
Generating public/private ed25519 key pair.
Your identification has been saved in /home/dev/.ssh/id_ed25519_demo
Your public key has been saved in /home/dev/.ssh/id_ed25519_demo.pub
The key fingerprint is:
SHA256:6CYdey7vv4GVH4wsMnzRWwjUFcuUYe/MEq2fSBV+ZDI demo@example
The key's randomart image is:
+--[ED25519 256]--+
|       .o. .*+E o|
|         o.=.= * |
|        . o = = .|
|     . . o * B . |
|      * S * * +  |
|     o * + o = . |
|    . = o . o o  |
|     o.o   .     |
|       ++.o.     |
+----[SHA256]-----+
total 8
-rw------- 1 root root 399 Oct  3 15:20 id_ed25519_demo
-rw-r--r-- 1 root root  94 Oct  3 15:20 id_ed25519_demo.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIPcU9HdeLevyWMe3C++QpY6eKyoKSXW17DZNCAOjfYkN demo@example
256 SHA256:6CYdey7vv4GVH4wsMnzRWwjUFcuUYe/MEq2fSBV+ZDI demo@example (ED25519)

The fingerprint, randomart, key text, owner, and time differ on every run. The sizes (399 and 94 bytes) are stable for ed25519 with this comment.

Option Meaning
-t ed25519 Key type. Ed25519 keys are short and fast. -t rsa -b 4096 creates an RSA key for older servers.
-C "demo@example" Comment stored at the end of the public key. It labels the key and has no effect on security.
-f path File name for the private key. The public key gets .pub added.
-N "" New passphrase. An empty string means no passphrase. A key meant for real use gets a passphrase (Example 6).
-l -f file Print the fingerprint of an existing key instead of making a new one.

Reading the output: the private key file has mode 600 (-rw-------), so only its owner can read it. The public key has mode 644 and is safe to share. The fingerprint is a short hash of the public key. Two people compare fingerprints to confirm they mean the same key. The randomart image is a picture of that hash, meant for quick visual comparison.

Without -f, ssh-keygen asks for a file name and suggests ~/.ssh/id_ed25519. The default name is the one ssh tries automatically, so a key with a custom name has to be named with -i or in ~/.ssh/config (Examples 3 and 4).

Example 2: Authorize the key with authorized_keys

A server logs a user in without a password when the user's public key appears in ~/.ssh/authorized_keys on the server. The file holds one public key per line.

Terminal
cat ~/.ssh/id_ed25519_demo.pub >> ~/.ssh/authorized_keys
chmod 700 ~/.ssh
chmod 600 ~/.ssh/authorized_keys
ls -ld ~/.ssh
ls -l ~/.ssh/authorized_keys
wc -l ~/.ssh/authorized_keys
Output
drwx------ 2 root root 4096 Oct  3 15:20 /home/dev/.ssh
-rw------- 1 root root 94 Oct  3 15:20 /home/dev/.ssh/authorized_keys
1

>> appends, so existing keys stay. A single > would replace the file and lock out every other key. wc -l counts one line: one authorized key.

On a real server the public key is sent with ssh-copy-id -i ~/.ssh/id_ed25519_demo.pub user@host. That command logs in with a password once, appends the key to the remote authorized_keys, and sets safe permissions. Here the client and the server use the same file, so a local append is enough.

The chmod lines matter. By default sshd has StrictModes on and refuses a key file that other users can write to, or a ~/.ssh directory that others can write to. The modes 700 for the directory and 600 for the file satisfy it. The demo server runs with StrictModes no only because its files sit in a scratch directory.

Example 3: Log in to localhost with ssh

The basic form is ssh user@host. A command after the host runs on the remote machine and the connection ends when it finishes.

Terminal
ssh -p 2222 -i ~/.ssh/id_ed25519_demo root@127.0.0.1 "echo connected"
Output
Host key verification failed.

The first connection to a server shows its host key fingerprint and asks for confirmation. The command ran without a terminal for a prompt, so it stopped. The host key proves the server's identity, in the same way a user key proves the client's. Accepting it once stores it in ~/.ssh/known_hosts, and later connections compare against the stored copy.

Terminal
ssh -p 2222 -i ~/.ssh/id_ed25519_demo -o StrictHostKeyChecking=accept-new root@127.0.0.1 "echo connected"
cut -c1-60 ~/.ssh/known_hosts
ssh -p 2222 -i ~/.ssh/id_ed25519_demo -o BatchMode=yes root@127.0.0.1 "echo second time"
Output
Warning: Permanently added '[127.0.0.1]:2222' (ED25519) to the list of known hosts.
connected
|1|Qc41XUKzODeZChx2eaRUjafQRIQ=|kWwDUdxhsS62pNhkibjsNutLyWM=
second time
Option Meaning
-p 2222 Server port. The default is 22.
-i file Private key to offer. Without it, ssh tries the default key names and any keys in the agent.
-o StrictHostKeyChecking=accept-new Store the host key of a new server without asking. A changed key for a known server is still refused.
-o BatchMode=yes Never prompt. Fail at once if a password, passphrase, or confirmation would be needed. Useful in scripts.

The known_hosts line is hashed (it starts with |1|), so the file does not list the names of the servers a user has visited. The value differs on every run.

To see how the login happened, -v prints debug messages. The filter below keeps the three lines that show the key exchange:

Terminal
ssh -p 2222 -i ~/.ssh/id_ed25519_demo -v -o BatchMode=yes root@127.0.0.1 true 2>&1 | grep -E "Authenticated|Offering public|Server accepts key"
Output
debug1: Offering public key: /home/dev/.ssh/id_ed25519_demo ED25519 SHA256:6CYdey7vv4GVH4wsMnzRWwjUFcuUYe/MEq2fSBV+ZDI explicit
debug1: Server accepts key: /home/dev/.ssh/id_ed25519_demo ED25519 SHA256:6CYdey7vv4GVH4wsMnzRWwjUFcuUYe/MEq2fSBV+ZDI explicit
Authenticated to 127.0.0.1 ([127.0.0.1]:2222) using "publickey".

The client offers the public key, the server finds it in authorized_keys and accepts it, and the client then proves it holds the private key. -v goes to stderr, so 2>&1 joins it to the pipe.

When no usable key exists, the server refuses the login:

Terminal
ssh -p 2222 -o IdentitiesOnly=yes -i /nonexistent -o BatchMode=yes root@127.0.0.1 true
Output
Warning: Identity file /nonexistent not accessible: No such file or directory.
root@127.0.0.1: Permission denied (publickey,keyboard-interactive).

IdentitiesOnly=yes stops ssh from trying other keys. The text in parentheses lists the methods the server would accept. Permission denied followed by a method list is the usual sign that the server did not find a matching key. The exit code is 255, the value ssh uses for its own errors.

On macOS the same commands work with the system ssh. The file locations are the same.

Example 4: Name a host in ~/.ssh/config

Typing the port, user, and key on every command is error-prone. ~/.ssh/config stores them under a short name.

Terminal
cat > ~/.ssh/config <<'CFG'
Host demo
    HostName 127.0.0.1
    Port 2222
    User root
    IdentityFile ~/.ssh/id_ed25519_demo
    IdentitiesOnly yes
    StrictHostKeyChecking accept-new
CFG
chmod 600 ~/.ssh/config
ssh demo "echo via config"
ssh -G demo | grep -E "^(hostname|port|user|identityfile|identitiesonly) "
Output
via config
user root
hostname 127.0.0.1
port 2222
identitiesonly yes
identityfile /home/dev/.ssh/id_ed25519_demo
Line Meaning
Host demo Starts a block. The name demo is what is typed on the command line. Patterns such as Host *.example.com or Host * match many names.
HostName The real address to connect to.
Port, User Replace -p and the user@ prefix.
IdentityFile Private key for this host.
IdentitiesOnly yes Offer only the listed key.
StrictHostKeyChecking accept-new Same behavior as in Example 3, stored per host.

ssh -G demo prints the final settings ssh would use for that name, one per line, without connecting. The filtered lines confirm the block was read. In the full output ssh -G also prints many defaults.

The first value found for an option wins, and blocks are read from the top of the file. Specific hosts therefore go first and Host * defaults go last. The aliases also work for scp and rsync, which Examples 8 to 12 use.

Example 5: Run a remote command

A command after the host name runs there, and its output returns to the local terminal. The exit code of the remote command becomes the exit code of ssh.

Terminal
mkdir -p /home/dev/demo/site
echo "hello from the remote service" > /home/dev/demo/site/index.html
echo "p" > /home/dev/demo/site/page2.html
ssh demo "echo one; echo two"
ssh demo "ls /home/dev/demo/site"
ssh demo "exit 3"; echo "exit code: $?"
ssh demo "grep -c ^ /etc/hostname"
echo "alpha beta" | ssh demo "tr a-z A-Z"
ssh demo "cat /nonexistent"; echo "exit code: $?"
Output
one
two
index.html
page2.html
exit code: 3
1
ALPHA BETA
cat: /nonexistent: No such file or directory
exit code: 1
Case What it shows
"echo one; echo two" The quotes keep the whole string together. The remote shell then splits it at ;, so both commands run remotely.
ssh demo "exit 3" The remote exit status 3 comes back as the local $?.
echo ... | ssh demo "tr ..." Standard input of the local pipe becomes standard input of the remote command. Output returns the same way, so ssh works as a link in a pipeline.
cat /nonexistent The error text comes from the remote cat and arrives on the local stderr. The exit code 1 is the remote one.

Quoting decides where a variable is expanded. In ssh demo "ls $DIR" the local shell replaces $DIR before sending. In ssh demo 'ls $DIR' the single quotes send the text unchanged and the remote shell expands it. For commands with pipes, quote the whole command so the pipe runs on the remote side: ssh demo "ps aux | wc -l" counts remote processes, while ssh demo ps aux | wc -l pipes the remote output into a local wc.

Without a command, ssh demo opens an interactive shell. With a command and a need for a prompt (for example sudo or top), -t requests a terminal: ssh -t demo top.

Example 6: Hold a passphrase-protected key in ssh-agent

A passphrase encrypts the private key file, so a stolen file is useless without it. Typing the passphrase on every login is tedious, so ssh-agent keeps the decrypted key in memory for a session. ssh-add loads keys into the agent.

The demo creates a key with a passphrase and authorizes it. The passphrase appears on the command line here only so the demo runs without prompts. On a real key, omit -N and type the passphrase at the prompt, because command-line arguments are visible to other users in the process list.

Terminal
ssh-keygen -q -t ed25519 -C "agent-demo" -N "demo-pass" -f ~/.ssh/id_ed25519_agent
cat ~/.ssh/id_ed25519_agent.pub >> ~/.ssh/authorized_keys
ssh -p 2222 -i ~/.ssh/id_ed25519_agent -o IdentitiesOnly=yes -o BatchMode=yes root@127.0.0.1 "echo hi"
Output
root@127.0.0.1: Permission denied (publickey,keyboard-interactive).

With BatchMode=yes the client cannot ask for the passphrase, so it cannot use the key and the login fails. An agent solves this.

Terminal
eval "$(ssh-agent -s)"
ssh-add -l
ssh-add ~/.ssh/id_ed25519_agent
ssh-add -l
ssh -p 2222 -o BatchMode=yes root@127.0.0.1 "echo hi from agent key"
ssh-add -D
ssh-add -l
Output
Agent pid 11873
The agent has no identities.
Identity added: /home/dev/.ssh/id_ed25519_agent (agent-demo)
256 SHA256:Iz+e34+CqCdnYR2ddrZ2UUGBDxNTlU6hMDPQWyM3ydY agent-demo (ED25519)
hi from agent key
All identities removed.
The agent has no identities.

The agent process id and the fingerprint vary. In the recorded run, ssh-add read the passphrase from a helper program (the SSH_ASKPASS variable) instead of a prompt. In a terminal, ssh-add asks for it once.

Command Meaning
eval "$(ssh-agent -s)" Start an agent. It prints shell commands that set SSH_AUTH_SOCK and SSH_AGENT_PID. eval runs them so this shell and its children can find the agent.
ssh-add file Decrypt the key (asks for the passphrase) and keep it in the agent.
ssh-add -l List loaded keys by fingerprint. Exit code 1 and "no identities" mean the agent is empty.
ssh-add -D Remove all keys from the agent.
ssh-agent -k Stop the agent started in this shell.

The login line has no -i option and no passphrase. ssh asked the agent through SSH_AUTH_SOCK, and the agent signed the login challenge without exposing the private key. Agent forwarding (ssh -A) lets a remote machine use the local agent for a further hop. It gives anyone with admin rights on that machine the ability to use the loaded keys while the connection lasts, so it is limited to hosts that are trusted.

On macOS, ssh-add --apple-use-keychain stores the passphrase in the system keychain, and UseKeychain yes with AddKeysToAgent yes in the config file loads it automatically. These are options of the Apple build of OpenSSH.

Example 7: Forward a local port with ssh -L

Local port forwarding makes a port on the client reach a port on, or reachable from, the server. It lets a local tool talk to a service that is not exposed to the network, such as a database or an internal web page.

The demo runs a small web server on port 8000 as the "remote service". Port 8080 is the local end of the tunnel.

Terminal
cd /home/dev/demo/site && python3 -m http.server 8000 --bind 127.0.0.1 >/dev/null 2>&1 &
sleep 1
curl -s http://127.0.0.1:8080/ || echo "nothing listens on 8080"
ssh -f -N -L 8080:127.0.0.1:8000 -o ExitOnForwardFailure=yes demo
curl -s http://127.0.0.1:8080/
ss -tln | grep -E ":(8080|8000) " | awk '{print $1, $4}'
Output
nothing listens on 8080
hello from the remote service
LISTEN 127.0.0.1:8000
LISTEN 127.0.0.1:8080

Before the tunnel, nothing listens on 8080 and curl fails. After ssh -f -N -L ..., the same curl returns the page that the service on port 8000 serves. ss -tln lists listening TCP sockets: port 8000 is the service, port 8080 is the tunnel end held by ssh.

Part Meaning
-L 8080:127.0.0.1:8000 Listen on local port 8080. Send each connection through the SSH link, and have the server connect to 127.0.0.1:8000 as seen from the server.
-N Do not run a remote command. The connection exists only for the forward.
-f Move ssh to the background after authentication, so the shell is free.
-o ExitOnForwardFailure=yes Exit with an error if the local port cannot be bound, instead of staying connected without a forward.

The address after the first port is resolved on the server. For a database that only the server can reach, -L 5433:db.internal:5432 lets a local client connect to 127.0.0.1:5433. The local end listens on 127.0.0.1 by default, so other machines cannot use it unless the listen address is given (-L 0.0.0.0:8080:...) and the server allows it.

If the remote side has nothing listening, the tunnel still opens, and the connection through it is closed:

Terminal
ssh -N -f -L 8080:127.0.0.1:1 demo
curl -s -o /dev/null -w "%{http_code}\n" http://127.0.0.1:8080/
Output
000

curl prints 000 when no HTTP response arrived. The tunnel was up, and the server could not connect to port 1.

A forward started with -f keeps running until its process is stopped. Find it with pgrep -f "L 8080" and end it with kill. The demo stopped both the tunnel and the web server before moving on. The reverse direction, -R, opens a port on the server that leads back to the client. A dynamic forward, -D 1080, turns the connection into a SOCKS proxy.

Example 8: Copy files with scp

scp copies files between machines using SSH for transport. It uses the same host names and the same ~/.ssh/config entries as ssh.

Terminal
cd /home/dev/demo
mkdir -p src/lib out remote
echo "line 1" > notes.txt
printf 'a\nb\n' > src/main.txt
echo lib > src/lib/util.txt
scp notes.txt demo:/home/dev/demo/remote/
ls /home/dev/demo/remote
Output
notes.txt

scp printed nothing: when output goes to a script or a pipe, progress meters are off, and success is silent. The file is now in the destination directory. The form is scp source destination, and a path that contains host: is the remote one.

Downloading reverses the arguments:

Terminal
scp demo:/home/dev/demo/remote/notes.txt /home/dev/demo/out/copy.txt && cat out/copy.txt
Output
line 1

Directories need -r (recursive):

Terminal
scp -r src demo:/home/dev/demo/remote/
ls -R /home/dev/demo/remote
Output
/home/dev/demo/remote:
notes.txt
src

/home/dev/demo/remote/src:
lib
main.txt

/home/dev/demo/remote/src/lib:
util.txt

Without a config entry, the port and the key are given on the command line. scp uses a capital -P for the port, while ssh uses lowercase -p. Lowercase -p in scp means "preserve times and modes".

Terminal
scp -P 2222 -i ~/.ssh/id_ed25519_demo notes.txt root@127.0.0.1:/home/dev/demo/remote/n2.txt

The command printed nothing and exited with status 0.

Option Meaning
-r Copy a directory and its contents.
-P port Remote port (capital P).
-i file Private key.
-p Keep modification times and modes.
-C Compress data in transit.
-q Quiet. Hides the progress meter in a terminal.

scp always copies every file in full. It has no way to skip files that are already present, and it cannot delete. These gaps are what rsync fills. Recent OpenSSH versions run scp over the SFTP protocol, so the behavior of remote glob characters such as * depends on the version. A quoted remote pattern such as scp 'demo:/logs/*.log' . works in current releases.

Example 9: Synchronize a directory with rsync -avz

rsync compares the source and the destination and sends only what differs. It is the usual tool for repeated copies of the same directory.

Terminal
cd /home/dev/demo
rm -rf remote && mkdir -p remote
mkdir -p project/docs
echo "readme v1" > project/README.md
echo "guide" > project/docs/guide.md
echo "debug" > project/app.log
rsync -avz project/ demo:/home/dev/demo/remote/project/
Output
sending incremental file list
created directory /home/dev/demo/remote/project
./
README.md
app.log
docs/
docs/guide.md

sent 315 bytes  received 153 bytes  312.00 bytes/sec
total size is 22  speedup is 0.05
Option Meaning
-a Archive mode. Short for -rlptgoD: recurse, copy symlinks as symlinks, and keep permissions, times, group, owner, and device files.
-v List each transferred file.
-z Compress data during transfer. It saves bandwidth on slow links and costs CPU on fast ones.

The summary line sent 315 bytes received 153 bytes counts bytes on the wire. speedup is the total size divided by the bytes sent. It is below 1 here because the files are tiny and the protocol overhead dominates. The byte counts and the rate vary between runs.

Change one file and run the same command again:

Terminal
echo "readme v2" > project/README.md
rsync -avz project/ demo:/home/dev/demo/remote/project/
rsync -avz project/ demo:/home/dev/demo/remote/project/
Output
sending incremental file list
README.md

sent 218 bytes  received 42 bytes  520.00 bytes/sec
total size is 22  speedup is 0.08
sending incremental file list

sent 157 bytes  received 13 bytes  340.00 bytes/sec
total size is 22  speedup is 0.13

The second run sent only README.md, the changed file. The third run listed no files at all, because nothing differed. rsync decides by comparing size and modification time first, and -a preserves times, which makes this comparison reliable. For large changed files, rsync also sends only the changed blocks.

Patterns skip files with --exclude:

Terminal
rsync -avz --exclude "*.log" project/ demo:/home/dev/demo/remote/clean/
ls -A /home/dev/demo/remote/clean
Output
sending incremental file list
created directory /home/dev/demo/remote/clean
./
README.md
docs/
docs/guide.md

sent 257 bytes  received 132 bytes  778.00 bytes/sec
total size is 16  speedup is 0.04
README.md
docs

app.log was not copied. A pattern with a slash, such as --exclude "docs/", matches paths. --exclude-from=file reads patterns from a file.

rsync uses ssh for remote copies by default. -e "ssh -p 2222 -i key" selects a different remote shell command and options when no config entry exists.

Example 10: Preview a copy with --dry-run

--dry-run (short form -n) goes through the whole comparison and reports what would change, without changing anything. It is the safe first step before any large or destructive copy.

Terminal
echo "new file" > project/docs/new.md
rsync -avzn project/ demo:/home/dev/demo/remote/project/
ls /home/dev/demo/remote/project/docs
Output
sending incremental file list
docs/
docs/new.md

sent 193 bytes  received 19 bytes  141.33 bytes/sec
total size is 31  speedup is 0.15 (DRY RUN)
guide.md

The summary ends with (DRY RUN). The listing shows docs/new.md would be sent, and the following ls confirms the remote directory still holds only guide.md.

--itemize-changes (-i) adds a column that says why each line is listed:

Terminal
rsync -avz --itemize-changes --dry-run project/ demo:/home/dev/demo/remote/project/
Output
sending incremental file list
.d..t...... docs/
<f+++++++++ docs/new.md

sent 193 bytes  received 19 bytes  424.00 bytes/sec
total size is 31  speedup is 0.15 (DRY RUN)
Code Meaning
< The file would be sent to the remote side. (> marks a file received.)
f, d The item is a file or a directory.
+++++++++ The item is new. Each position stands for an attribute, and plus signs mean all are new.
.d..t...... A directory whose modification time (t) differs. Nothing else changes.

The directory line appears because adding a file changes the directory's time, and -a keeps times in step.

Example 11: Mirror a directory with --delete

By default rsync only adds and updates files. --delete also removes files from the destination that no longer exist in the source, which makes the destination an exact mirror.

Remove a source file, then preview before running:

Terminal
rm project/docs/guide.md
rsync -avz --dry-run --delete project/ demo:/home/dev/demo/remote/project/
Output
sending incremental file list
deleting docs/guide.md
docs/
docs/new.md

sent 173 bytes  received 43 bytes  432.00 bytes/sec
total size is 25  speedup is 0.12 (DRY RUN)

The preview shows deleting docs/guide.md. The listing also shows docs/new.md again, because the earlier run in Example 10 was a dry run and sent nothing. Run it for real after reading the list:

Terminal
rsync -avz --delete project/ demo:/home/dev/demo/remote/project/
ls /home/dev/demo/remote/project/docs
Output
sending incremental file list
deleting docs/guide.md
docs/
docs/new.md

sent 230 bytes  received 63 bytes  586.00 bytes/sec
total size is 25  speedup is 0.09
new.md

The remote docs directory now has new.md only, the same as the source.

--delete removes data permanently, and a wrong destination path removes the wrong files. These habits limit the risk:

  • Run with --dry-run first and read every deleting line.
  • Check that the source is the directory intended. An empty or missing source with --delete empties the destination.
  • Use --backup --backup-dir=DIR to move replaced and deleted files into a directory instead of removing them.
  • Use --max-delete=N to stop if more than N files would be deleted.

Files that are excluded are also protected from deletion on the destination, unless --delete-excluded is given.

Example 12: Trailing slashes in rsync paths

A trailing slash on the source changes what rsync copies. This rule is the most common source of surprises with rsync.

  • src/ means the contents of src.
  • src (no slash) means the directory src itself.

The slash on the destination does not change the result in the cases below. The demo copies a small tree to a local destination, which follows the same rule as the remote form.

Terminal
cd /home/dev/demo
mkdir -p a/inner b
echo x > a/inner/f.txt
echo y > a/top.txt
rsync -a --dry-run -v a/ b/
Output
sending incremental file list
top.txt
inner/
inner/f.txt

sent 141 bytes  received 26 bytes  334.00 bytes/sec
total size is 4  speedup is 0.02 (DRY RUN)

With a/, the listed names are top.txt and inner/. The contents of a would land directly inside b. Run both forms for real and list the result with find:

Terminal
rsync -a a b/ && find b | sort
rm -rf b && mkdir b
rsync -a a/ b/ && find b | sort
Output
b
b/a
b/a/inner
b/a/inner/f.txt
b/a/top.txt
b
b/inner
b/inner/f.txt
b/top.txt
Command Result
rsync -a a b/ Creates b/a/.... The directory a is placed inside b.
rsync -a a/ b/ Creates b/top.txt and b/inner/.... The contents of a go into b.

The dry run for the no-slash form lists names that start with a/, which confirms the extra level:

Terminal
rsync -a --dry-run -v a b
Output
sending incremental file list
a/
a/top.txt
a/inner/
a/inner/f.txt

sent 152 bytes  received 30 bytes  364.00 bytes/sec
total size is 4  speedup is 0.02 (DRY RUN)

The trailing slash matters most together with --delete. rsync -a --delete project/ host:backup/ makes everything in backup match project, including the removal of unrelated files that were already in backup. rsync -a --delete project host:backup/ makes only backup/project match, and leaves the rest of backup alone. Mixing the two forms by accident either removes files outside the intended directory or leaves a nested copy behind. A dry run (Example 10) shows which level the paths land on.

scp has no such rule: scp -r src host:dir/ always creates dir/src.

The slash rule is the same on macOS. macOS ships its own rsync build, which can be older than the 3.2.7 release used here, so a newer option may be missing. rsync --version shows the installed release, and a current build can be installed with Homebrew.

After the examples, the demo server was stopped with kill $(cat ~/sshd-demo/sshd.pid), and ss -tln | grep -c 2222 printed 0, which confirms nothing listens on port 2222.

Interview points

  • A key pair has a private part that never leaves the client and a public part listed in the server's ~/.ssh/authorized_keys. Login proves possession of the private key without sending it.
  • StrictModes makes sshd refuse key files and directories with loose permissions. ~/.ssh should be 700 and authorized_keys and private keys 600.
  • known_hosts records server host keys. A changed host key stops the connection, because it can mean a man-in-the-middle attack or a rebuilt server. accept-new trusts only servers seen for the first time.
  • ssh host cmd returns the remote command's exit code and passes stdin and stdout through, so it works inside pipelines. Quote the command to decide which side expands variables and runs pipes.
  • ~/.ssh/config blocks set HostName, Port, User, and IdentityFile per name. The first matching value wins, so specific hosts come before Host *. ssh -G name prints the final result.
  • ssh-agent keeps decrypted keys in memory and answers sign requests through SSH_AUTH_SOCK. Agent forwarding exposes those keys to admins of the remote host.
  • -L local:host:remote listens on a local port and sends connections through the server to host:remote. -R is the reverse direction and -D creates a SOCKS proxy.
  • scp takes -P for the port and -r for directories and always copies whole files. ssh takes -p for the port.
  • rsync -a keeps permissions, times, and links and skips files whose size and time match. -z compresses, and --exclude skips patterns.
  • --dry-run previews, and --delete removes extra destination files. Preview before deleting.
  • A trailing slash on the rsync source means "the contents of this directory". Without it the directory itself is copied.

Summary

  • ssh-keygen -t ed25519 -C comment -f path creates a key pair. The private file has mode 600. -l -f key.pub prints the fingerprint.
  • Appending a public key to ~/.ssh/authorized_keys on the server (or running ssh-copy-id) enables key login. Use >>, not >.
  • ssh -p port -i key user@host connects. The default port is 22 and the first connection stores the host key in ~/.ssh/known_hosts.
  • -o BatchMode=yes makes ssh fail instead of prompting. -o StrictHostKeyChecking=accept-new stores new host keys.
  • Permission denied (publickey,...) means no offered key matched authorized_keys. ssh -v shows which keys were offered.
  • ~/.ssh/config provides short host names with HostName, Port, User, IdentityFile, and IdentitiesOnly. The alias works for ssh, scp, and rsync.
  • ssh host "command" runs a remote command. Its exit code is returned, and -t allocates a terminal.
  • ssh-agent holds unlocked keys. eval "$(ssh-agent -s)" starts it, ssh-add loads a key, ssh-add -l lists keys, and ssh-add -D removes them.
  • ssh -f -N -L lport:host:rport server opens a background tunnel from a local port to host:rport as seen from the server.
  • scp [-r] [-P port] source destination copies over SSH. A path with host: is remote.
  • rsync -avz source/ host:dest/ copies only changed files, keeps metadata, and compresses data. A second run with no changes lists no files.
  • --dry-run (-n) reports a change without making it, and --itemize-changes shows the reason for each line. --delete removes destination files that are absent from the source.
  • rsync a/ b/ copies the contents of a into b. rsync a b/ creates b/a.