SSD Nodes Learn Hosting plans →
Guides Matt ConnorBy Matt Connor

Stream a directory over SSH with tar

Copy a directory to another server with tar and SSH: why -f - is needed, where -C goes, who owns the extracted files, and compression in the pipe.

What the pipe actually does

Stream a directory over SSH with tar and no archive file is ever created: tar -cf - site writes the archive to standard output, the pipe carries those bytes, and a second tar -xf - reads them from standard input and writes the tree back out on the other side. Nothing lands on disk in between, so you never need free space for a .tar you plan to delete two minutes later. The local version and the SSH version are the same command with ssh in the middle, which is why it is worth building the local one first.

Copy a directory locally before you add a network

Run this on one machine. If the local pipe does not produce an identical tree, adding a network link will not fix it, it will only make the failure harder to read.

mkdir -p /tmp/lab/site/conf /tmp/lab/site/data /tmp/lab/copy
printf 'server_name example.com;\n' > /tmp/lab/site/conf/nginx.conf
printf 'DB_HOST=127.0.0.1\n' > /tmp/lab/site/conf/app.env
head -c 200000 /dev/urandom > /tmp/lab/site/data/blob.bin
chmod 600 /tmp/lab/site/conf/app.env
tar -cf - -C /tmp/lab site | tar -xf - -C /tmp/lab/copy
diff -r /tmp/lab/site /tmp/lab/copy && echo trees match

diff -r walks both directories and prints a line for every name or content that differs, so silence is the healthy result, and the && echo trees match turns that silence into something you can see. diff compares names and file contents. It says nothing about permissions or ownership, which is the next thing to check.

find /tmp/lab/site -printf '%P %M %s\n' | sort > /tmp/lab/a.txt
find /tmp/lab/copy/site -printf '%P %M %s\n' | sort > /tmp/lab/b.txt
diff /tmp/lab/a.txt /tmp/lab/b.txt && echo metadata matches

%P prints each path with the starting directory removed, so the two listings line up and can be compared. %M is the permission string and %s is the size in bytes. This is GNU find, so on Alpine or a BSD you would build the same listing with stat instead.

Why -f - is needed at all

-f names the archive tar should write or read, and - is the conventional name for standard output on create and standard input on extract. It is not redundant with the pipe, because tar does not look at what its output is connected to. It opens whatever -f told it to open.

Leave -f out and GNU tar falls back to the TAPE environment variable, and then to a default chosen when the binary was built. Run tar --show-defaults and read the value your own build uses. Some builds default to standard output and some to a tape device, and busybox tar and bsdtar make their own decisions, so a line that works on your laptop can fail on the server you are copying to. If tar would end up reading an archive from your terminal it stops instead and points at the missing -f. Write -f - every time. It costs three characters and removes a whole class of surprise.

Where -C belongs on each side

-C dir means change into dir first. GNU tar processes it in order with the file names around it, so on the create side the position of -C decides what the member names look like.

tar -cf - -C /tmp/lab site changes into /tmp/lab and stores members as site/conf/nginx.conf. tar -cf - /tmp/lab/site archives the same files, but tar strips the leading slash, warns on standard error that it is doing so, and stores members as tmp/lab/site/conf/nginx.conf. Extract that into /srv and you get /srv/tmp/lab/site, which is nobody's intention.

Two habits keep this straight. On the create side write -C parent child, so the archive holds exactly one top-level directory. On the extract side write -C dest, so that one directory lands where you meant. You can see the result before you move anything, because -t lists members without writing files.

tar -cf - -C /tmp/lab site | tar -tf -

Read the first line of that output. It is the prefix the receiving tar will create under its own -C directory, and checking it takes a second.

Who owns the files after extraction

Ownership is recorded in the archive, but the extracting tar decides whether to honour it, and an ordinary user is not permitted to give a file away to somebody else. So the answer depends entirely on who runs the second tar.

Run as an ordinary user, GNU tar extracts everything as that user and that user's group, and it does not treat that as an error. Root gets the opposite default and restores the owner recorded in the archive. In tar's own option names, root defaults to --same-owner and everyone else defaults to --no-same-owner. Look at it rather than trusting it.

tar -cf - -C /tmp/lab site | tar -xf - -C /tmp/lab/copy
ls -ln /tmp/lab/site/conf
ls -ln /tmp/lab/copy/site/conf

ls -ln prints numeric UID and GID instead of names, which is the honest view when two machines may not agree on names. Run the pipe as root and the two listings show the same numbers. Run it as an ordinary user and the copy carries your own UID and GID whatever the source said.

Permissions split the same way. For an ordinary user tar applies your umask to the modes it restores, so with the common umask of 022 a source file at mode 600 arrives at 600 while one at 666 arrives at 644. -p, spelled out as --preserve-permissions, turns the masking off and asks for the exact recorded mode. Root already behaves as if -p were given.

--numeric-owner matters when the two machines disagree about names. tar records both the numeric IDs and the user and group names for every member, and you can read that out of the stream itself:

tar -cf - -C /tmp/lab site | tar -tvf -

-v gives the listing an owner column, and that column shows user/group by name. --numeric-owner tells tar to work from the stored numbers and ignore the names, and it belongs on the create side and the extract side alike. Names are what you want when the destination assigned its service accounts in a different order. Numbers are what you want when both machines run the same distribution and the same packages, because the same rules assigned the IDs. Choose one and use it on both ends of the pipe. This single detail decides whether a move to a new VPS comes up serving traffic or comes up with a daemon that cannot read its own data directory.

A root-owned tree needs a root-owned extraction, which means sudo on the far end:

sudo tar -cf - -C /srv site | ssh user@newhost 'sudo tar -xpf - -C /srv'

That only works if sudo on the far end is configured not to ask for a password. There is no terminal on that side of the connection, and standard input is already carrying the archive, so sudo has nowhere to read a password from and stops with a message about the missing terminal. Grant NOPASSWD for that one command, or extract into a directory your login user already owns and correct ownership afterwards.

Put compression in the pipe

The pipe carries bytes, so a compressor drops into the middle of it. Two forms do the same job.

tar -czf - -C /tmp/lab site | wc -c
tar -cf - -C /tmp/lab site | gzip | wc -c

-z makes tar run gzip itself. The second form runs gzip as a separate process, which is what you need when you want flags that tar's built-in call will not pass. wc -c counts the bytes that came out the end, so you can measure the effect on your own data instead of guessing at it.

tar -cf - -C /tmp/lab site | wc -c
tar -cf - -C /tmp/lab site | gzip -6 | wc -c
tar -cf - -C /tmp/lab site | zstd -T0 -3 | wc -c

Three numbers from the same input. The gap between the first and the other two is a property of your data, not of the tool. Configuration files and logs shrink a lot because they repeat themselves. The random bytes in blob.bin do not shrink at all, because there is no repetition to remove, and images and video behave the same way: compressing them spends CPU for nothing.

zstd comes from sudo apt install zstd on Ubuntu, and GNU tar has understood it natively as --zstd since version 1.31. For any other compressor, -I names the program together with its flags, as in tar -I 'zstd -T0 -3' -cf - -C /tmp/lab site.

Whether to compress at all depends on the link. On a slow or metered connection, compression wins because the network is the bottleneck. On a fast connection, single-threaded gzip becomes the slowest part of the pipe and an uncompressed stream finishes sooner. zstd with -T0 uses every core, which usually keeps the compressor out of the critical path. Do not add ssh's own -C on top of a compressed stream, because that spends CPU compressing data that no longer compresses.

Stream a directory over SSH with tar

Here is the form everyone is looking for. Run it against your own two machines. Nothing has changed except that the second tar lives on another host.

tar -cf - -C /srv site | ssh user@newhost 'tar -xf - -C /srv'

Read it left to right. The first tar writes the archive to standard output. ssh connects your standard input to the standard input of the remote command, and that remote command is a second tar reading standard input. The single quotes keep your local shell from expanding anything inside the remote command before ssh ever sees it.

Four details decide whether this works on the first attempt.

  • The destination must exist. ssh user@newhost 'mkdir -p /srv/backup && tar -xf - -C /srv/backup' creates it inside the same connection, and mkdir does not read standard input, so the archive still reaches tar untouched.
  • Never add -t to ssh. A pseudo-terminal transforms bytes on the way through, and an archive that passes through one arrives corrupt.
  • Set up key authentication first. A password prompt or a first-time host key prompt stalls the transfer until you answer it, and from a script with no terminal attached ssh gives up instead of asking. That is the same key handling every other SSH task depends on.
  • The exit status of a pipeline is the status of its last command, so the sending tar can fail while the line still looks successful. Run set -o pipefail first in bash, or read ${PIPESTATUS[0]} afterwards.

To pull instead of push, put the archiving tar on the remote side and the extracting tar on yours:

ssh user@oldhost 'tar -cf - -C /srv site' | tar -xf - -C /srv

There is no resume. If the link drops at ninety percent you start again, and the partial tree at the destination is still sitting there waiting to confuse you. For a transfer measured in hours, start it inside a terminal multiplexer, which is what running a long command under tmux or screen is for. It protects the process from your own session ending. It does not protect the stream from the network path failing, so plan for a restart either way.

Verify after the copy rather than assuming. Hashing every file and then hashing the sorted result gives one value to compare:

cd /srv/site && find . -type f -exec sha256sum {} + | LC_ALL=C sort | sha256sum

Run that same line on both machines and compare the two single hashes. It reads every byte on both sides, so it is slow on a large tree, and it is the only check that proves content rather than counts. It is the same checksum habit that catches a truncated download, applied to a directory.

When rsync is the better choice

Both tools copy directories over SSH, and they are good at different jobs.

tar sends one stream. There is no negotiation per file, so a tree of ten thousand small files costs one round trip rather than ten thousand, and the transfer runs at close to whatever the link and the disks allow. It needs nothing on the far end beyond tar and a shell, which matters on a minimal image.

rsync compares before it sends. It skips files that already match, resumes a broken transfer with --partial, removes files at the destination that are gone at the source, and can run again next week to carry only what changed. That comparison costs a round trip and a stat call per file, which is exactly why the very first copy of a huge tree of small files is often slower than tar's.

The decision follows from which behaviour you need. A one-shot copy of a tree that is not changing goes to tar. A repeated copy, a resumable copy, or keeping two trees in step goes to rsync. Neither one is a backup, because both faithfully copy the current state, including a deletion you regret or a file some ransomware just encrypted. Backups need history and verification, which means a deduplicating snapshot tool such as restic instead.

What goes wrong

tar warns about a leading slash. Passing an absolute path such as /srv/site makes tar strip the leading / and say so on standard error. This is a warning and the transfer still completes, but the member names now begin with srv/, so the tree lands one level deeper than you expected under the receiving -C. Writing -C /srv site avoids it completely.

Nothing useful arrives and the shell reports success. The pipeline's status came from the last command, so a failure in the sending tar was thrown away. Set pipefail or inspect ${PIPESTATUS[@]}, and judge the create side by its exit status rather than by its byte count. A create that fails immediately still writes the end-of-archive padding, so tar -cf - -C /tmp/lab nosuchdir | wc -c prints 10240 while tar exits 2. The status is the signal, the size is not.

Permission denied during extraction. The receiving tar names the path it could not create and finishes with a failure status. Either the destination belongs to another user or a parent directory does not allow you to write. Extract into a directory you own, or arrange passwordless sudo for the remote tar.

Files are missing, or a whole extra disk arrives. tar crosses filesystem boundaries unless you pass --one-file-system. A volume mounted underneath the source directory is the usual surprise, and it goes wrong in both directions. Decide which behaviour you want before you start a long transfer.

One unreadable file ends the run. tar reports the file it could not read and exits with a failure status at the end. Everything sent before that point is valid, so the destination holds a partial tree rather than a corrupt one. Fix that file and run again.

A sparse file becomes huge. tar records hard links correctly, so two names for one inode arrive as two names for one inode. Sparse files are different. Without --sparse on the create side tar reads the holes as real zero bytes and writes them out in full, so a thinly provisioned disk image can arrive at its full nominal size and fill the destination.

FAQ

Why does tar need -f - when the output already goes into a pipe?

Because tar never inspects what its output is connected to. -f names the archive, and tar opens exactly what that option says. With no -f, GNU tar uses the TAPE environment variable if it is set, and otherwise a default compiled into the binary, which differs between builds and between implementations such as busybox tar and bsdtar. Run tar --show-defaults to see yours. Writing -f - explicitly means the command behaves the same on every machine you paste it into.

Who owns the files after tar extracts them on the other server?

The user who ran the receiving tar, unless that user is root. An ordinary user cannot give a file away, so tar silently extracts everything as that user and their primary group. Root restores the owner recorded in the archive instead. Check with ls -ln on both sides, which prints numeric IDs rather than names. If the two machines assign different UIDs to the same service account, add --numeric-owner on both ends to copy the numbers, or leave it off to match by name.

How do I check that the copy is identical?

Locally, diff -r source dest prints one line per difference and stays silent when the trees match. Across two machines, run find . -type f -exec sha256sum {} + | LC_ALL=C sort | sha256sum from inside each directory and compare the single hash it prints. That reads every byte, so it is slow on a big tree, and it is the only check that proves content instead of file counts. Permissions and ownership need a separate comparison, such as two sorted find -printf '%P %M' listings.

Can the receiving tar run under sudo?

Only if sudo on that host does not ask for a password. ssh user@host 'sudo tar -xpf - -C /srv' gives sudo no terminal to prompt on, and standard input is already carrying the archive, so sudo stops with a message about the missing terminal and the transfer dies. Grant NOPASSWD for that specific command, or extract into a directory your login user owns and adjust ownership as a separate step afterwards.

Should I use tar over SSH or rsync?

Use tar when you want one fast copy of a tree that is not changing, especially many small files, because there are no per-file round trips and nothing beyond tar is needed on the far end. Use rsync when you will run it more than once, when you need to resume after a dropped link, or when you want the destination kept in step with the source. Neither is a backup, since both reproduce the current state including deletions and damage.

#tar#ssh#shell#file-transfer#migration