scp is the command everyone learns first and then quietly stops using, usually after the first time a transfer dies at 90% and has to start over. But it is still the lowest-friction way to move a file between two machines, because it needs nothing on the remote side except sshd. rsync needs rsync installed at both ends. sftp needs an interactive session or a batch file.
This is a reference for the cases where scp is genuinely the right tool, plus the exact points where it stops being one.
The two protocols hiding behind one command
Since OpenSSH 9.0 (April 2022), scp transfers files over the SFTP protocol by default. Before that, it used the old RCP-derived protocol, which had a nasty class of path-traversal bugs (CVE-2019-6110, CVE-2019-6111, CVE-2019-6109).
You can check what you are running:
ssh -V If the result is older than 9.0, scp still uses the legacy protocol unless you force the new one:
scp -s sftp file.txt user@host:/tmp/ This matters in two practical ways. First, the legacy protocol can silently overwrite files outside the target directory when the server is malicious — not your usual threat model, but relevant if you pull from untrusted hosts. Second, the protocol change altered how some wildcard and quoting edge cases behave.
The flags you will actually type
scp source user@host:/path/ Copy the other way:
scp user@host:/path/file.txt ./local-dir/ | Flag | Meaning |
|---|---|
-P 2222 | Remote port (capital P — ssh uses lowercase -p) |
-r | Recurse into directories |
-p | Preserve modification times and permissions (lowercase) |
-C | Compress in transit |
-l 8000 | Bandwidth limit in Kbit/s |
-q | Quiet |
-i key.pem | Identity file |
-J bastion | Jump through a bastion host (OpenSSH 7.3+) |
-3 | Route traffic between two remote hosts through your machine |
The -p / -P collision is the single most common source of confusion. -P is the port, -p preserves attributes. Get it backwards and you will either connect to port 22 or lose your timestamps.
Copying between two remote hosts
The naive version routes the data through your laptop:
scp user1@hostA:/data/dump.sql user2@hostB:/backup/ That is -3 behaviour by default (in older OpenSSH it was the only behaviour). If both hosts can reach each other, tell scp to connect them directly:
scp -R user1@hostA:/data/dump.sql user2@hostB:/backup/ -R requires that hostA can resolve and authenticate to hostB. In a datacenter that is usually true and it is dramatically faster. Across the public internet it usually is not, and you want the -3 route instead.
Quoting is where scp hurts
scp passes the remote path to a remote shell, so the string goes through two rounds of expansion — your local shell, then the remote one. Spaces and globs need double escaping:
scp "user@host:/path/with\ spaces/file.txt" . Then the remote glob is expanded remotely, which is what you want:
scp "user@host:/var/log/*.gz" ./logs/ But a local glob in the destination position is a common mistake:
# wrong: the star is sent to the remote shell verbatim in some versions scp file1 file2 user@host:/backup/* Use scp file1 file2 user@host:/backup/ and let the directory name be the target.
What scp cannot do
This is the part that pushes people to rsync, and it is worth being blunt about the limits:
- No resume. A dropped connection means starting the file over.
- No exclusion patterns. There is no
--exclude. - No delta transfer. Every byte is sent, every time.
- No dry run. You cannot preview what would change.
- Poor feedback. One progress line per file, no aggregate ETA.
For a 400 MB log file over a flaky connection, scp is the wrong tool. For a 12 KB config file, it is the right one and rsync is overkill.
rsync as the upgrade path
The minimum useful invocation:
rsync -avz --partial --progress ./build/ user@host:/var/www/app/ -
-a= archive: recurse, preserve symlinks, permissions, times, owner/group. It implies-r -l -p -t -g -o -D. -
-vverbose,-zcompress. -
--partialkeeps a partially transferred file so a retry resumes instead of restarting. This is the flag that solves the actualscppain point.
Exclusions, which scp has no answer for:
rsync -avz --exclude 'node_modules' --exclude '.git' --exclude '*.log' ./ user@host:/srv/app/ Dry run before a destructive sync:
rsync -avzn --delete ./ user@host:/srv/app/ -n (or --dry-run) prints what would happen. Never run --delete without -n first. A trailing-slash mistake turns rsync -a ./src host:/dst into "create /dst/src" instead of "sync the contents of src into /dst", and combined with --delete that is how people erase directories.
When the remote has no rsync
Common on appliances, minimal containers, and older NAS firmware. Two workarounds that need nothing but ssh:
Stream a tar:
tar czf - ./project | ssh user@host 'tar xzf - -C /srv/' Resume a large file with dd:
ssh user@host 'tail -c +$(( $(stat -c%s /srv/big.iso 2>/dev/null || echo 0) + 1 ))' >> big.iso The dd trick is fragile and easy to get wrong by one byte. If resumption matters, install rsync on the remote; it is a package in every distribution and a brew install on macOS.
SFTP when you want to look around
scp is one-shot. If you need to list, cd, and fetch several files, the interactive client is faster than chaining six scp commands:
sftp user@host Then ls, cd, get, put, lcd, lls. Batch mode skips the prompt:
sftp -b commands.txt user@host Both scp and sftp speak the same subsystem, so if one works the other will.
Practical checklist
- Port goes in
-P, attributes go in-p. - Add
-Cfor text-heavy transfers, skip it for already-compressed files (.gz,.zip, images) — it just burns CPU. - Use
-Jinstead of hand-rolling aProxyCommandfor a single hop. - Reach for
rsync --partialthe moment a transfer is large or the link is unreliable. - Test
--deletewith-nevery single time. - If you script this, put the host in
~/.ssh/configso you stop repeating-iand-Pflags.
That last point is worth more than it sounds: a host alias turns
scp -P 2222 -i ~/.ssh/prod_key -J bastion user@10.0.3.14:/var/log/app.log . into
scp prod:/var/log/app.log . If you want a starting point for the config file, there is a working template with Host, ProxyJump, ServerAliveInterval and per-host keys at dartshell.com/tools/ssh-config-example.
For building the scp command itself — including the port, key, and jump-host flags without looking them up — there is a form at dartshell.com/tools/scp-command-generator. It is part of DartShell, a native macOS client that handles SSH, SFTP, RDP, VNC and serial sessions in one window, which is where I ended up putting all of these commands after typing them too many times.