# SCP

> Copy files over SSH with scp, understand its SFTP-based transfer since OpenSSH 9.0, and know when rsync, sftp or tar is the better tool.

Canonical: https://www.wiki.jodisand.me/scp/
Reviewed: 2026-09-24
Related: [SSH](https://www.wiki.jodisand.me/ssh/index.md), [Bash](https://www.wiki.jodisand.me/bash/index.md)


## Cheatsheet

| Task | Command |
| --- | --- |
| Upload | `scp file host:/tmp/` |
| Download | `scp host:/tmp/file .` |
| Directory | `scp -r dir host:/srv/` |
| Keep mtime, atime and mode | `scp -p file host:/tmp/` |
| Non-standard port | `scp -P 2222 file host:/tmp/` |
| Specific key | `scp -i ~/.ssh/id_deploy file host:/tmp/` |
| Through a bastion | `scp -J bastion file host:/tmp/` |
| Between two remotes, via this machine | `scp a:/f b:/f` |
| Between two remotes, directly | `scp -R a:/f b:/f` |
| Cap bandwidth (Kbit/s) | `scp -l 8000 big.tar host:/tmp/` |
| Quiet, for scripts | `scp -q -B file host:/tmp/` |
| Legacy SCP protocol | `scp -O file host:/tmp/` |
| Debug a failure | `scp -v file host:/tmp/` |

`-P` (capital) is the port for `scp` because `-p` preserves times and modes. `ssh` uses `-p` for the port. Mixing them up is the most common scp error: `scp -p 2222 ...` treats `2222` as a file name.

## How scp transfers files

`scp` opens an [SSH](https://www.wiki.jodisand.me/ssh/) connection and, since OpenSSH 9.0, uses the **SFTP protocol** over it by default. It authenticates and encrypts exactly like `ssh`, and everything in `~/.ssh/config` applies: a `Host` entry with `ProxyJump`, `User` and `IdentityFile` makes `scp file db-01:/tmp/` work without flags.

Before 9.0 it used the legacy SCP/RCP protocol, which ran `scp -t` or `scp -f` on the server through the remote shell. That design had two consequences that the SFTP mode removes:

| Behaviour | Legacy SCP protocol (`-O`) | SFTP mode (default since 9.0) |
| --- | --- | --- |
| Remote paths | Passed through the remote shell, so spaces and metacharacters needed a second layer of quoting | Sent as literal file names; quote once for your local shell |
| Remote globs (`host:'*.log'`) | Expanded by the remote shell | Expanded by scp using SFTP directory listing |
| `~user/` paths | Expanded by the remote shell | Needs the `expand-path@openssh.com` extension (OpenSSH 8.7+ `sftp-server`) |
| Server requirement | `scp` binary on the server | SFTP subsystem enabled on the server |

Since OpenSSH 8.7, copies between two remote hosts go through the local machine by default (`-3` behaviour), so neither remote host needs credentials for the other. `-R` restores the old direct copy, which requires the origin host to authenticate to the destination without a password.

Use `-O` only for servers without an SFTP subsystem (some embedded devices and network gear) or scripts that depend on remote shell expansion. Administrators can block the legacy protocol by creating a world-readable `/etc/ssh/disable_scp`.

## Syntax

```sh
scp [options] source ... target
scp file.txt host:/remote/dir/             # local to remote
scp host:/remote/file.txt ./local/         # remote to local
scp alice@host:/path/file .                # explicit user
scp -r ./dir host:/srv/                    # recursive; follows symlinks it meets
scp 'host:/var/log/*.log' ./logs/          # quote so the local shell does not expand the glob
scp 'host:/tmp/my file.txt' .              # SFTP mode: one level of quoting is enough
scp a.example.com:/f b.example.com:/f      # remote to remote, relayed through this machine
scp file '[2001:db8::1]:/tmp/'             # IPv6 literal needs brackets
scp ./a:b.txt host:/tmp/                   # a local name containing ':' needs a path prefix
```

When downloading, scp checks that the file names the server sends match what you asked for. `-T` disables that check for glob patterns the check rejects, at the cost of trusting the server not to send unexpected files.

## When not to use scp

| Situation | Better tool |
| --- | --- |
| Large or repeated transfers | `rsync -avzP`: sends only changes, resumable, shows progress |
| Directory trees that must match exactly | `rsync -av --delete` (dry run first) |
| Many small files | `tar` over SSH: one stream instead of per-file round trips |
| Interactive browsing, or scripted put/get with error handling | `sftp`, with `-b batchfile` |
| Mounting instead of copying | `sshfs` |

```sh
rsync -avzP ./dir/ host:/srv/dir/                  # trailing slash on the source copies its contents
rsync -av --delete --dry-run ./dir/ host:/srv/dir/ # preview what --delete would remove
tar czf - ./dir | ssh host 'tar xzf - -C /srv'     # fast for thousands of small files
ssh host 'tar czf - /var/log/nginx' > nginx-logs.tgz
```

> [!WARNING]
> `rsync --delete` removes files on the target that are not in the source. Run it with `--dry-run` first, and check the trailing slash: `./dir` and `./dir/` copy to different places.

## sftp

`sftp` speaks the same protocol scp now uses, through the same `sftp-server` or `internal-sftp` subsystem, but exposes it as a session: list, change directory, transfer, and get a per-command exit status. It is the right tool when a script must react to a missing file or a failed upload rather than discover it afterwards from scp's single exit code.

```sh
sftp host                         # interactive session; ssh_config applies
sftp -P 2222 -i ~/.ssh/id_deploy alice@host
sftp host:/var/log/app.log .      # non-interactive single download
sftp host:/var/log/               # opens the session in that directory
```

Inside the session the common commands are `ls -l`, `cd`, `pwd`, `get`, `put`, `mkdir`, `rename`, `rm`, `df` and `bye`; the local equivalents are `lls`, `lcd` and `lpwd`. `get` and `put` take `-r` for directories, `-p` to preserve times and modes, and `-a` to resume a partial file; `reget` and `reput` are shorthand for the `-a` form. `progress` toggles the progress meter.

Batch mode reads commands from a file and needs non-interactive authentication (key, agent or certificate); it exits non-zero on the first failing `get`, `put`, `reget`, `reput`, `rename`, `ln`, `rm`, `mkdir`, `chdir`, `ls`, `lchdir`, `copy`, `cp`, `chmod`, `chown`, `chgrp`, `lpwd`, `df`, `symlink` or `lmkdir`. Prefix a command with `-` to ignore its failure and with `@` to suppress its echo.

```sh
cat > upload.sftp <<'EOF'
-mkdir /srv/app/releases
cd /srv/app/releases
put -p my-app-1.4.2.tar.gz
ls -l my-app-1.4.2.tar.gz
EOF
sftp -q -b upload.sftp deploy@host      # -q hides the progress meter; exit status reflects the batch
```

Tuning flags: `-R num_requests` sets the outstanding requests per transfer (default 64) and `-B buffer_size` the buffer per request (default 32768 bytes); raise both on high-latency links. `-l limit` caps bandwidth in Kbit/s and `-f` asks the server to flush each file to disk after transfer, useful before a remote process consumes it.

## rsync

`rsync` compares source and destination file lists first, then sends only the files (and with the delta algorithm, only the blocks) that differ. Over SSH it runs an `rsync` process on the far end, so both sides need the binary; `--rsync-path` names it when it is not on the remote `PATH`.

```sh
rsync -av ./dir/ host:/srv/dir/                     # -a = -rlptgoD: recurse, links, perms, times, owner, group, devices
rsync -avz --partial --progress big.iso host:/srv/  # -z compresses; --partial keeps a half-sent file for resumption
rsync -av -e 'ssh -p 2222 -i ~/.ssh/id_deploy' ./dir/ host:/srv/dir/   # options for the transport
rsync -avn --itemize-changes ./dir/ host:/srv/dir/  # -n dry run, -i shows what would change and why
rsync -av --files-from=list.txt / host:/backup/     # absolute paths in list.txt, relative to the / given
rsync -av --exclude='*.log' --exclude='.git/' ./dir/ host:/srv/dir/
rsync -av --bwlimit=5m ./dir/ host:/srv/dir/        # rate in KiB/s unless suffixed; 5m = 5 MiB/s
rsync -av --rsync-path='sudo rsync' ./dir/ host:/etc/app/   # write as root without root login
rsync -avc ./dir/ host:/srv/dir/                    # -c compares checksums, not size and mtime; slow but exact
rsync -av --link-dest=/backup/prev ./dir/ host:/backup/today/   # unchanged files become hard links to prev
```

The itemize output reads left to right: `>f+++++++++` is a new file being sent, `>f.st......` an existing file whose size and time changed, `*deleting` a removal. `--info=progress2` prints a single overall progress line instead of one per file.

Filter rules are evaluated in order and the first match wins, so put includes before the exclude that would otherwise catch them:

```sh
rsync -av --include='*/' --include='*.conf' --exclude='*' ./etc/ host:/srv/etc/   # only .conf files, keeping the tree
```

`--delete` variants: `--delete` removes destination files absent from the source, `--delete-excluded` also removes files the exclude rules skipped. `--backup --backup-dir=/srv/.rsync-backup` keeps a copy of anything overwritten or deleted, which turns a mistaken sync into a recoverable one. `--max-size` and `--min-size` skip files outside a size range, and `--timeout` aborts a stalled transfer rather than hanging forever.

## Batching many files

Each file in an SFTP transfer costs at least one round trip for the open, plus one for the close, so thousands of small files on a 100 ms link take minutes regardless of bandwidth. Three ways out, in order of preference:

```sh
rsync -a ./dir/ host:/srv/dir/                       # pipelines the file list; best for repeated syncs
tar cf - -C ./dir . | ssh host 'tar xf - -C /srv/dir'   # one stream; add z on both sides only for compressible data
tar cf - -C ./dir . | ssh -o Compression=yes host 'tar xf - -C /srv/dir'   # compress the SSH channel instead
scp -r ./dir host:/srv/                              # last resort; follows symlinks and needs a round trip per file
```

`tar` with `-C` changes directory before archiving so the archive holds relative paths; without it a `tar cf - /srv/dir` archive unpacks to `srv/dir` under the target. Pipe through `pv` (`tar cf - . | pv | ssh ...`) to see throughput on a stream that has no progress meter.

For many hosts, run transfers in parallel rather than serially. `xargs -P` limits concurrency, and `ssh -o BatchMode=yes` (which `scp -B` sets) turns a password prompt into an immediate failure instead of a hang.

```sh
xargs -a hosts.txt -P8 -I{} scp -q -B -o ConnectTimeout=10 config.yaml {}:/etc/app/
```

Through a bastion, `-J` (or `ProxyJump` in `ssh_config`) works for `scp`, `sftp` and `rsync -e 'ssh -J bastion.example.com'` alike, and never places the file on the bastion. Reusing one connection across many copies removes the handshake cost: set `ControlMaster auto`, `ControlPath ~/.ssh/cm-%r@%h:%p` and `ControlPersist 10m` for the host in `ssh_config`, and subsequent `scp`, `sftp` and `rsync` runs ride the open socket. See [SSH](https://www.wiki.jodisand.me/ssh/) for the config syntax.

## Resuming and verifying

`scp` cannot resume: an interrupted copy leaves a truncated file and a second run starts from zero. Use `sftp reget`/`reput` or `rsync --partial`. Plain `--partial` keeps the partial file and rsync's delta algorithm skips the matching blocks on the next run; `--append-verify` assumes the existing prefix is correct, appends the rest and checksums the whole file at the end, which is faster on a link where the transfer stalled but wrong if the source changed meanwhile. `--inplace` writes directly into the destination file rather than a temporary copy, which saves space for huge files but leaves a corrupt destination if the transfer dies.

Verify after any transfer that matters. rsync checks a whole-file checksum on every file it transfers, so a clean exit is already a verification; scp and tar do not.

```sh
sha256sum big.iso                                      # local
ssh host 'sha256sum /srv/big.iso'                      # remote; compare by eye or with the script below
ssh host 'cd /srv && sha256sum -c -' < checksums.txt   # remote verification of many files from a local manifest
```

Bandwidth and compression: `scp -l` and `sftp -l` take Kbit/s; `rsync --bwlimit` takes KiB/s (or a suffix such as `5m`). Compression (`-C` for scp, `-z` for rsync) helps text and logs and slows already-compressed archives, images and encrypted files, because the cipher has to wait for a compressor that gains nothing.

## Troubleshooting

| Symptom | Cause | Check or fix |
| --- | --- | --- |
| `2222: No such file or directory`, or it connects to port 22 | Used `-p` (preserve) instead of `-P` (port) | `scp -P 2222 ...` |
| `subsystem request failed on channel 0` or `Connection closed` immediately | Server has no SFTP subsystem | Enable `Subsystem sftp` in `sshd_config`, or use `-O` |
| `scp: Received message too long` / garbage before transfer | Shell startup file on the server prints output for non-interactive sessions | Make `.bashrc` return early when not interactive |
| Glob or `~` path worked before, fails now | Script relied on remote shell expansion of the legacy protocol | Rewrite the path, or `-O` |
| `protocol error: filename does not match request` (legacy `-O` mode) | Filename check rejected a server-side glob result | Narrow the pattern, or `-T` if you trust the server |
| Remote-to-remote copy fails authenticating | `-R` used and origin host cannot reach the target without a password | Drop `-R` so data relays through this machine |
| Slow on many small files | Per-file round trips | Use `tar` over SSH or `rsync` |
| Any authentication or host key error | Same as ssh | `scp -v`, then see [SSH troubleshooting](https://www.wiki.jodisand.me/ssh/#troubleshooting) |
| Transfer stalls, then `Connection reset` | Idle link killed by a NAT or firewall | `-o ServerAliveInterval=30`; resume with `rsync --partial` |
| `scp: dest open "/srv/f": Permission denied` | Target directory not writable by the SSH user | `ssh host 'ls -ld /srv'`; use `rsync --rsync-path='sudo rsync'` |
| `rsync: command not found` on the remote | rsync missing from the remote `PATH` or not installed | `ssh host 'command -v rsync'`; `--rsync-path=/usr/local/bin/rsync` |
| `rsync: connection unexpectedly closed` | Remote shell startup printed output, or the remote rsync died | `ssh host true \| wc -c` must be 0; check remote disk with `df` |
| Truncated file after an interrupted scp | scp cannot resume | Re-run with `rsync --partial --progress` or `sftp` then `reget` |
| Second rsync run copies everything again | `-a` missing, so times were not preserved and every file looks changed | Add `-a` or `-t`; `--itemize-changes` shows the reason per file |
| Hangs waiting for a password from cron | No TTY and no key | `scp -B` or `ssh -o BatchMode=yes`; load a key or agent |
| Directory copied to `/srv/dir/dir` | Source given without trailing slash to rsync, or target directory already existed for scp | `rsync ./dir/ host:/srv/dir/`; `scp -r ./dir host:/srv/` creates or reuses `/srv/dir` |
| Symlink dereferenced into a full copy | `scp -r` follows symbolic links it meets | Use `rsync -a` (copies the link itself) or `tar` |

## Oneliners

```sh
# Copy, then compare checksums on both ends
scp app.tar host:/tmp/ && ssh host 'sha256sum /tmp/app.tar' && sha256sum app.tar

# Fan a file out to many hosts, 8 at a time
printf '%s\n' web{1..10}.example.com | xargs -P8 -I{} scp -q -B config.yaml {}:/etc/app/

# Pull the same log from each host into a per-host file
for h in web1 web2; do scp -q "$h:/var/log/app.log" "app-$h.log"; done

# Copy only if the remote copy is older
rsync -avu file host:/srv/

# Resume an interrupted large transfer
rsync --partial --progress --append-verify big.iso host:/srv/

# Tune SFTP concurrency for a high-latency link (defaults: 64 requests, 32 KB buffer)
scp -X nrequests=128 big.iso host:/srv/

# Restrict a deploy key to file transfer only, in authorized_keys
# restrict,command="internal-sftp" ssh-ed25519 AAAA...placeholder deploy@ci

# Copy through a bastion without landing the file on it
scp -J bastion.example.com app.tar host:/tmp/

# Upload with a non-default port and key without touching ssh_config
scp -P 2222 -i ~/.ssh/id_deploy -p app.tar deploy@host:/srv/

# Keep a connection open for 10 minutes so repeated copies skip the handshake
scp -o ControlMaster=auto -o ControlPath=~/.ssh/cm-%r@%h:%p -o ControlPersist=10m app.tar host:/tmp/

# Fail fast instead of hanging when a host is down
scp -q -B -o ConnectTimeout=10 config.yaml host:/etc/app/

# Cap at 1 Mbit/s so a backup does not saturate an office link
scp -l 1000 backup.tgz host:/backup/

# Download every .conf under a remote tree, keeping the directory structure
rsync -avm --include='*/' --include='*.conf' --exclude='*' host:/etc/app/ ./app-etc/

# Preview exactly what a mirror sync would add, change and delete
rsync -avn --delete --itemize-changes ./site/ host:/var/www/site/

# Mirror with a safety net: overwritten and deleted files go to a dated backup directory
rsync -av --delete --backup --backup-dir="/srv/.rsync-backup/$(date +%F)" ./site/ host:/var/www/site/

# One overall progress line for a large tree instead of one per file
rsync -a --info=progress2 ./data/ host:/srv/data/

# Copy as root on the target using sudo without root login
rsync -av --rsync-path='sudo rsync' ./etc-app/ host:/etc/app/

# Pull a remote directory and delete files on the remote only after they arrived
rsync -av --remove-source-files host:/var/spool/out/ ./inbox/

# Stream a directory as a tarball with throughput display
tar cf - -C ./dir . | pv | ssh host 'tar xf - -C /srv/dir'

# Pull a remote directory as a tarball, compressed on the remote
ssh host 'tar czf - -C /var/lib app' > app.tgz

# Pipe a remote database dump straight to a local file with no intermediate copy
ssh db.example.com 'pg_dump -Fc my-db' > my-db.dump

# Fetch a single file non-interactively with sftp
sftp -q host:/var/log/app.log .

# Resume a half-downloaded file
sftp host <<< 'reget /srv/big.iso big.iso'

# Batch upload that aborts on the first failure and returns non-zero
printf 'cd /srv/app\nput -p release.tgz\n' | sftp -q -b - deploy@host

# Verify many files on the remote against a local manifest
sha256sum ./dist/* | sed 's# ./dist/# #' | ssh host 'cd /srv/app && sha256sum -c -'

# Copy a directory between two remotes directly (origin must authenticate to target)
scp -R -r a.example.com:/srv/app b.example.com:/srv/

# Copy a file to a name containing spaces on the remote (SFTP mode: quote once)
scp report.pdf 'host:/srv/docs/Q3 report.pdf'

# Force IPv4 when a dual-stack host has broken IPv6
scp -4 app.tar host:/tmp/

# Find what changed on the remote since the last sync without transferring anything
rsync -avnc host:/etc/app/ ./app-etc/ | grep -v '/$'

# Sync only files modified in the last day (GNU find), keeping relative paths
find ./logs -type f -mtime -1 -print0 | rsync -av --files-from=- --from0 ./ host:/backup/logs/

# Hard-link snapshot backup: unchanged files cost no space
rsync -a --link-dest=../prev ./data/ host:/backup/snap-"$(date +%F)"/
```

## Scripts

Push a release archive to a fleet in parallel, verify each copy by checksum and report the hosts that failed.

```sh
#!/usr/bin/env bash
set -euo pipefail
# usage: fanout.sh <file> <remote-dir> < hosts.txt
file=${1:?file required}; dest=${2:?remote dir required}
sum=$(sha256sum "$file" | cut -d' ' -f1)
tmp=$(mktemp -d); trap 'rm -rf -- "$tmp"' EXIT

push() {                                     # one host per invocation; called by xargs
  local host=$1
  if scp -q -B -o ConnectTimeout=10 "$file" "$host:$dest/" \
     && [[ $(ssh -o BatchMode=yes "$host" "sha256sum '$dest/${file##*/}'" | cut -d' ' -f1) == "$sum" ]]; then
    printf '%s ok\n' "$host"
  else
    printf '%s FAILED\n' "$host" >&2; printf '%s\n' "$host" >> "$tmp/failed"
  fi
}
export -f push; export file dest sum tmp
xargs -P8 -I{} bash -c 'push "$@"' _ {}

if [[ -s "$tmp/failed" ]]; then
  printf 'failed hosts:\n' >&2; cat "$tmp/failed" >&2; exit 1
fi
```

Mirror a directory to a remote with a dry run first, a dated backup of anything it would overwrite or delete, and a log for the audit trail.

```sh
#!/usr/bin/env bash
set -euo pipefail
src=${1:?source dir required}; dest=${2:?user@host:/path required}
log=/var/log/mirror-$(date +%F).log
common=(-a --delete --human-readable --stats --log-file="$log")
backup_dir=".rsync-backup/$(date +%FT%H%M%S)"

changes=$(rsync "${common[@]}" --dry-run --itemize-changes "$src/" "$dest/" | grep -cE '^[<>ch*]' || true)
printf '%s changes pending\n' "$changes"
(( changes > 0 )) || exit 0

rsync "${common[@]}" --backup --backup-dir="$backup_dir" "$src/" "$dest/"
printf 'done; backups of replaced files under %s/%s\n' "$dest" "$backup_dir"
```

Collect a fixed set of files from many hosts into per-host directories, with a timeout so one dead host does not stall the run.

```sh
#!/usr/bin/env bash
set -euo pipefail
# usage: collect.sh out-dir host... ; collects the paths listed in $PATHS (space separated)
out=${1:?output dir required}; shift
PATHS=${PATHS:-/etc/os-release /var/log/app.log}
rc=0
for host in "$@"; do
  mkdir -p "$out/$host"
  for p in $PATHS; do
    if ! timeout 60s scp -q -B -o ConnectTimeout=10 "$host:$p" "$out/$host/${p##*/}"; then
      printf '%s: failed to fetch %s\n' "$host" "$p" >&2; rc=1
    fi
  done
done
exit "$rc"
```

## Further reading

- [scp(1)](https://man.openbsd.org/scp) for the full flag list
- [OpenSSH 9.0 release notes](https://www.openssh.com/txt/release-9.0) for the SFTP switch and its compatibility notes
- [sftp(1)](https://man.openbsd.org/sftp) for batch mode and the interactive commands
- [rsync(1)](https://download.samba.org/pub/rsync/rsync.1) for filter rules and the itemize output format


