rsync a Synology NAS to a Linux Server: Pulling 5.8 TB over SSH
Pulling every share off a Synology NAS onto a Debian server with rsync over SSH: the script, tmux, logs, exit codes 23/24 and the DSM switch that blocks it.
- We copied a two-disk RAID0 Synology NAS (about 5.8 TB across several shares) to a Debian server on the same LAN with a plain
rsync -aH --numeric-ids --partialloop, run as root on the server inside tmux. - The first attempt failed on every share in about two seconds with exit code 12. SSH key login worked; the cause was the DSM rsync service being switched off. Turning it on fixed it immediately.
- Throughput sat at roughly 109–111 MB/s for the whole run, which is what a single gigabit link gives you. It held at the same rate after we moved the server to another subnet and restarted the job.
- Exit codes 23 and 24 are “partial transfer” codes, and we expected 23 on files the NAS login user cannot read. Any other non-zero code stops our watcher.
- At the time of writing the copy was still running. Checksum verification is the next step, and nothing on the NAS gets formatted until it passes.
Why we pulled the NAS onto a server instead of the cloud
The NAS in question had two disks in RAID0: 14 TB of raw space, no redundancy, about 5.8 TB used. Most of that was photo originals and video that can’t be replaced. We wanted to rebuild the volume with redundancy, and that means wiping it, so we needed a full, verified copy somewhere else first.
We first tried uploading everything to cloud storage. That fell apart on daily upload limits, and we wrote it up separately in why Google Drive was the wrong target for a NAS backup. The faster route was a rack server we already had: a Dell R730 with a hardware RAID6 array on a PERC H730P controller, running Debian 13 that we installed without a keyboard from an unattended preseed ISO. On a LAN there is no daily cap. The only limit is the wire.
Our estimate before starting: one gigabit port gives roughly 110 MB/s, so 5.8 TB is about 15 hours of copying. A checksum pass costs about the same again. That’s two days at most before the NAS could be wiped, against more than a week for the cloud.
The pull script: one rsync per share, one log per share
We pulled from the server side instead of pushing from the NAS. The server runs rsync as root, so it can write files with their original owners. It logs in to the NAS over SSH as an ordinary DSM user with key authentication. Here is the script with our addresses and names replaced by examples (192.168.88.10 is the NAS):
#!/bin/bash
# Pull every NAS share to /data/nas. rsync only reads the source: no --delete.
# Run as: sudo bash ~/nas-pull.sh (inside tmux) Logs: /data/nas-pull-logs/
set -u
LOG=/data/nas-pull-logs; mkdir -p "$LOG" /data/nas
CIPHER=aes128-gcm
SSH="ssh -i /home/admin/.ssh/id_ed25519 -o UserKnownHostsFile=/home/admin/.ssh/known_hosts \
-o Compression=no -c ${CIPHER}@openssh.com"
for s in photos homes docker video music media; do
echo "=== $s start $(date '+%F %T')" | tee -a $LOG/summary.txt
rsync -aH --numeric-ids --partial --info=progress2,stats2 -e "$SSH" \
"[email protected]:/volume1/$s/" "/data/nas/$s/" \
> "$LOG/$s.log" 2> "$LOG/$s.err"
rc=$?
echo "=== $s rc=$rc end $(date '+%F %T') errlines=$(wc -l < $LOG/$s.err)" | tee -a $LOG/summary.txt
done
echo "ALL DONE $(date '+%F %T')" | tee -a $LOG/summary.txt
What each choice does:
| Piece | Why |
|---|---|
-a |
Archive mode: recursion, permissions, times, symlinks, owner and group (owner and group only stick because the receiving side is root). |
-H |
Keeps hard links as hard links instead of making separate copies. |
--numeric-ids |
Keeps raw uid/gid numbers instead of mapping by name. The NAS and the server don’t share a user database, so name mapping would quietly reassign ownership. |
--partial |
Keeps a half-copied file if the job is interrupted, so a restart doesn’t begin that file again from zero. |
--info=progress2,stats2 |
One running total per share instead of a line per file, plus a summary at the end. |
-i and UserKnownHostsFile in -e |
rsync runs as root under sudo, and root has neither our key nor our known_hosts. Pointing at them explicitly avoids a root key the NAS has never seen. |
AES-128-GCM cipher (-c), Compression=no |
A cheap cipher and no compression. Photo and video files don’t compress, and on a gigabit link the CPU shouldn’t be the bottleneck. |
There’s deliberately no --delete anywhere. This job only ever adds files to the server. The order of the loop is also deliberate: irreplaceable photos first, then home folders and the docker share, then the media that could be found again.
The DSM rsync switch that has to stay on
The first run “finished” in two seconds:
=== photos rc=12 end 2026-10-01 02:44:30 errlines=3
=== homes rc=12 end 2026-10-01 02:44:31 errlines=3
...
ALL DONE 2026-10-01 02:44:32
Permission denied, please try again.
rsync: connection unexpectedly closed (0 bytes received so far) [Receiver]
rsync error: error in rsync protocol data stream (code 12) at io.c(285) [Receiver=3.5.0]
That Permission denied is misleading. With ssh -v the NAS clearly accepted the key, and a plain ssh [email protected] true from root with the same options worked. Only the rsync command on the far side was refused. On Synology, rsync over SSH is controlled by Control Panel > File Services > rsync > Enable rsync service. With that box unticked, DSM refuses the remote rsync even after a successful SSH login. Once it was enabled, a --dry-run against one folder returned exit code 0, and the real job started at about 109 MB/s within the first minute.
Two related settings we left alone. “Enable rsync account” is only for rsync-daemon logins with a separate password, and SSH key access doesn’t need it. Synology also has a per-user rsync permission under Application Privileges. If the switch is on and a login is still refused, check that the user is allowed there. Keep the service enabled for as long as anything pulls from the NAS. If someone switches it off “for security” halfway through, the next run fails the same way.
Running it in tmux and watching for the finish
A multi-hour copy should never depend on the SSH session that started it. We started the script in a detached tmux session on the server:
tmux new-session -d -s naspull "sudo bash ~/nas-pull.sh"
tmux attach -t naspull # look in; Ctrl-b d to leave it running
Progress is easy to read from anywhere because each share writes its own log. progress2 writes carriage returns, so we convert them to newlines and take the last line:
cat /data/nas-pull-logs/summary.txt
tail -c 400 /data/nas-pull-logs/photos.log | tr '\r' '\n' | grep -v '^$' | tail -1
# 2,893,222,930,324 97% 111.20MB/s 6:53:32 (xfr#78742, ir-chk=1812/82940)
A warning about that line: with incremental recursion (ir-chk), the percentage applies to the part of the file list rsync has scanned so far, not to the whole share. The byte count is the useful figure. At that check about 2.9 TB had been copied and /data showed 2.9 TB used, with an empty error log.
For a completion alert we didn’t keep a terminal open. We ran a polling loop on a workstation that wakes every five minutes. It exits when the summary says ALL DONE, or when any share reports a non-zero code other than 23 or 24:
until ! ssh -o ConnectTimeout=10 backup-server '
grep -q "ALL DONE" /data/nas-pull-logs/summary.txt && exit 1
grep -E "rc=[1-9]" /data/nas-pull-logs/summary.txt | grep -vqE "rc=(23|24)" && exit 1
exit 0' 2>/dev/null
do sleep 300; done
ssh backup-server 'cat /data/nas-pull-logs/summary.txt; du -sh /data/nas'
What rc 23 and rc 24 mean, and why we expect 23
From the rsync manual’s exit values:
| Code | Meaning | What we do |
|---|---|---|
| 0 | Success | Nothing. |
| 12 | Error in rsync protocol data stream | Usually never connected (our DSM switch). Stop and investigate. |
| 23 | Partial transfer due to error | Read the .err log. Some files weren’t copied. |
| 24 | Partial transfer due to vanished source files | Files deleted on the NAS mid-run. Normal on a live system. |
We expect 23 on the docker share. The NAS side of the transfer runs as an ordinary DSM user, and container volumes contain root-owned files that user can’t read. rsync copies everything else and reports those files in the error log. That isn’t acceptable for a backup you’re about to rely on, so root-owned data (container volumes, databases) gets a separate, proper dump with administrator rights on the NAS. Copying a running database’s files isn’t a backup anyway. The 23 is the signal to go and do that. It’s not something to suppress.
Restarting after the server’s IP changed
Partway through, we moved the backup server onto its own isolated subnet. It went from 192.168.88.20 to 10.0.20.20, with a firewall rule letting it reach only the NAS. That kills the running transfer, so we did it deliberately:
- Stopped the tmux session and confirmed no
rsyncprocesses were left. - Scheduled the network change with
systemd-run --on-active=60, plus a second timer five minutes later. That one restores the old interface config if the new gateway doesn’t answer, which protects you from locking yourself out of a remote box. - Once the server answered on the new address, we checked it could still reach the NAS over SSH and couldn’t reach anything else on the LAN.
- Moved the old logs aside (
/data/nas-pull-logsbecamenas-pull-logs.run1-<time>) so the new summary wouldn’t mix with the old one, then started the same tmux command again.
rsync’s default quick check compares size and modification time, so files already on the server were skipped instead of re-sent. The restarted job went straight back to 110.95 MB/s. The move didn’t make the copy faster. It held the same gigabit ceiling (about 109 MB/s at the start, 111 MB/s mid-run, 111 MB/s after the restart), which tells you the network was the limit the whole time, not the disks on either end.
Planned verification, and what we’d do differently
The copy was still running when this was written, so we can’t report final exit codes or a total time yet. What happens next is fixed, though: nothing on the NAS gets formatted until a checksum comparison passes. The plan is a full-content dry run per share:
sudo rsync -aHc --numeric-ids --dry-run --itemize-changes -e "$SSH" \
"[email protected]:/volume1/photos/" /data/nas/photos/ > /data/verify-photos.txt
# expect: no lines for regular files; anything listed differs in content or metadata
-c makes rsync read and checksum every file on both sides instead of trusting size and time. It’s slow, roughly as long as the copy itself, but it’s the only check that would catch a silently corrupted file. File counts per share (find ... -type f | wc -l on both sides) are a cheap extra check.
Changes we’d make next time:
- Make the summary honest. Our script printed
ALL DONEeven though every share had failed with code 12. It should count non-zero codes and printDONE WITH ERRORSinstead. Better still, it should stop after the first share fails within seconds, because that means the connection is broken, not the data. - Do a
--dry-runon one small folder first. It would have caught the DSM switch before the loop ever ran. - Send the summary somewhere. Polling from a workstation works, but a one-line push notification at the end of the script is simpler.
- Plan the root-owned data from the start. Know which shares contain files your SSH user can’t read, and dump them separately instead of discovering them through rc 23.