Software Engineering WikiSE Wiki

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.

Reviewed MarkdownEdit

On this page

Cheatsheet#

TaskCommand
Uploadscp file host:/tmp/
Downloadscp host:/tmp/file .
Directoryscp -r dir host:/srv/
Keep mtime, atime and modescp -p file host:/tmp/
Non-standard portscp -P 2222 file host:/tmp/
Specific keyscp -i ~/.ssh/id_deploy file host:/tmp/
Through a bastionscp -J bastion file host:/tmp/
Between two remotes, via this machinescp a:/f b:/f
Between two remotes, directlyscp -R a:/f b:/f
Cap bandwidth (Kbit/s)scp -l 8000 big.tar host:/tmp/
Quiet, for scriptsscp -q -B file host:/tmp/
Legacy SCP protocolscp -O file host:/tmp/
Debug a failurescp -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 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:

BehaviourLegacy SCP protocol (-O)SFTP mode (default since 9.0)
Remote pathsPassed through the remote shell, so spaces and metacharacters needed a second layer of quotingSent as literal file names; quote once for your local shell
Remote globs (host:'*.log')Expanded by the remote shellExpanded by scp using SFTP directory listing
~user/ pathsExpanded by the remote shellNeeds the expand-path@openssh.com extension (OpenSSH 8.7+ sftp-server)
Server requirementscp binary on the serverSFTP 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#

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#

SituationBetter tool
Large or repeated transfersrsync -avzP: sends only changes, resumable, shows progress
Directory trees that must match exactlyrsync -av --delete (dry run first)
Many small filestar over SSH: one stream instead of per-file round trips
Interactive browsing, or scripted put/get with error handlingsftp, with -b batchfile
Mounting instead of copyingsshfs
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.

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.

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.

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:

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:

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.

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 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.

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#

SymptomCauseCheck or fix
2222: No such file or directory, or it connects to port 22Used -p (preserve) instead of -P (port)scp -P 2222 ...
subsystem request failed on channel 0 or Connection closed immediatelyServer has no SFTP subsystemEnable Subsystem sftp in sshd_config, or use -O
scp: Received message too long / garbage before transferShell startup file on the server prints output for non-interactive sessionsMake .bashrc return early when not interactive
Glob or ~ path worked before, fails nowScript relied on remote shell expansion of the legacy protocolRewrite the path, or -O
protocol error: filename does not match request (legacy -O mode)Filename check rejected a server-side glob resultNarrow 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 passwordDrop -R so data relays through this machine
Slow on many small filesPer-file round tripsUse tar over SSH or rsync
Any authentication or host key errorSame as sshscp -v, then see SSH troubleshooting
Transfer stalls, then Connection resetIdle link killed by a NAT or firewall-o ServerAliveInterval=30; resume with rsync --partial
scp: dest open "/srv/f": Permission deniedTarget directory not writable by the SSH userssh host 'ls -ld /srv'; use rsync --rsync-path='sudo rsync'
rsync: command not found on the remotersync missing from the remote PATH or not installedssh host 'command -v rsync'; --rsync-path=/usr/local/bin/rsync
rsync: connection unexpectedly closedRemote shell startup printed output, or the remote rsync diedssh host true | wc -c must be 0; check remote disk with df
Truncated file after an interrupted scpscp cannot resumeRe-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 changedAdd -a or -t; --itemize-changes shows the reason per file
Hangs waiting for a password from cronNo TTY and no keyscp -B or ssh -o BatchMode=yes; load a key or agent
Directory copied to /srv/dir/dirSource given without trailing slash to rsync, or target directory already existed for scprsync ./dir/ host:/srv/dir/; scp -r ./dir host:/srv/ creates or reuses /srv/dir
Symlink dereferenced into a full copyscp -r follows symbolic links it meetsUse rsync -a (copies the link itself) or tar

Oneliners#

# 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.

#!/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.

#!/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.

#!/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#