rsync copies files efficiently by transferring only what has changed, and it works over SSH out of the box. With the --link-dest option it can also create dated snapshots in which unchanged files are hard links to the previous snapshot, so each day looks like a full backup but only uses space for new or changed files. In this tutorial you will push daily snapshots of an Ubuntu 24.04 server to a separate backup server, schedule them with a systemd timer, and restore files from them.

Prerequisites

To follow this guide you need:

  • A source server running Ubuntu 24.04 LTS (for example a CubePath VPS) with a non-root user with sudo privileges. This is the server you want to back up.
  • A backup server running Ubuntu 24.04 or Debian 12, ideally in a different data center, with enough disk space for your data plus history, and SSH access with a sudo user.
  • The backup server's IP address, referred to as your_backup_server_ip.

rsync is installed by default on Ubuntu 24.04. Check it on both servers:

rsync --version | head -n 1
rsync  version 3.2.7  protocol version 31

If the command is missing, install it with sudo apt install rsync.

Step 1 - Understanding the rsync basics

Before automating anything, it helps to see the two rsync behaviors that cause most mistakes. Create a test directory:

mkdir -p ~/rsync-test/src && touch ~/rsync-test/src/file1 ~/rsync-test/src/file2

A trailing slash on the source copies the contents of the directory. Without it, rsync copies the directory itself:

rsync -av ~/rsync-test/src/ ~/rsync-test/contents/
rsync -av ~/rsync-test/src ~/rsync-test/dir/
ls ~/rsync-test/contents ~/rsync-test/dir
/home/your_user/rsync-test/contents:
file1  file2

/home/your_user/rsync-test/dir:
src

The second behavior is --dry-run (-n), which shows what would happen without changing anything. Use it every time you try a new command, especially with --delete:

rsync -avn --delete ~/rsync-test/src/ ~/rsync-test/contents/

The options used throughout this guide are:

  • -a (archive): recursive, and preserves permissions, timestamps, symlinks, owner and group.
  • -H, -A, -X: preserve hard links, ACLs and extended attributes.
  • -R (relative): keep the full source path, so /var/www is stored as var/www instead of www.
  • --numeric-ids: store user and group IDs as numbers instead of mapping them by name.

Remove the test directory when you are done:

rm -r ~/rsync-test

Step 2 - Preparing the backup server

On the backup server, create a dedicated, unprivileged user that will receive the backups. Do not name it backup, because Ubuntu already has a system account with that name:

sudo adduser --disabled-password --gecos "" rbackup

Create the directory that will hold the snapshots and give it to the new user:

sudo mkdir -p /srv/backups
sudo chown rbackup:rbackup /srv/backups
sudo chmod 700 /srv/backups

Because rbackup is not root, it cannot set file ownership directly. rsync solves this with --fake-super: the receiving side stores the original owner, group, permissions, ACLs and extended attributes in user.rsync.* extended attributes, and puts them back when you restore. ext4 and XFS support user extended attributes by default.

Step 3 - Creating an SSH key for unattended backups

The backup job runs as root on the source server (it needs to read files like /etc/shadow), so the key belongs to root. Generate a dedicated key without a passphrase so the job can run unattended:

sudo ssh-keygen -t ed25519 -f /root/.ssh/backup_ed25519 -N "" -C "rsync-backup@$(hostname -s)"

Print the public key:

sudo cat /root/.ssh/backup_ed25519.pub

On the backup server, create the .ssh directory for rbackup and open its authorized_keys file:

sudo -u rbackup mkdir -m 700 -p /home/rbackup/.ssh
sudo -u rbackup nano /home/rbackup/.ssh/authorized_keys

Paste the public key on one line, prefixed with the restrict option. restrict disables port forwarding, agent forwarding and terminal allocation for this key, which rsync does not need:

restrict ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAA... rsync-backup@web01

Set the permissions SSH expects:

sudo chmod 600 /home/rbackup/.ssh/authorized_keys

Back on the source server, connect once to accept the host key and confirm the login works:

sudo ssh -i /root/.ssh/backup_ed25519 rbackup@your_backup_server_ip 'echo connected'

Type yes when asked to confirm the fingerprint. The output should be:

connected

Step 4 - Writing the backup script

The script creates a new snapshot directory named after the current date and time, uses the previous snapshot as --link-dest, and only marks the snapshot as complete once rsync succeeds. That way an interrupted run never becomes the base for the next one.

Create an exclude list first, for data that should not be backed up:

sudo mkdir -p /etc/rsync-backup
sudo nano /etc/rsync-backup/excludes
/home/*/.cache/
/root/.cache/
/var/www/*/cache/
*.swp

Now create the script:

sudo nano /usr/local/sbin/rsync-backup
#!/usr/bin/env bash
set -euo pipefail

REMOTE="rbackup@your_backup_server_ip"
SSH_KEY="/root/.ssh/backup_ed25519"
REMOTE_DIR="/srv/backups/$(hostname -s)"
SOURCES=(/etc /home /root /var/www)
EXCLUDES="/etc/rsync-backup/excludes"
KEEP_DAYS=14

STAMP="$(date +%F_%H%M)"
SSH_CMD="ssh -i ${SSH_KEY} -o BatchMode=yes"

# Make sure the host directory exists on the backup server.
${SSH_CMD} "${REMOTE}" "mkdir -p '${REMOTE_DIR}'"

# Copy into a temporary directory, hard-linking unchanged files to the last snapshot.
rsync -aHAXR --numeric-ids \
  --rsync-path="rsync --fake-super" \
  --exclude-from="${EXCLUDES}" \
  --link-dest="${REMOTE_DIR}/latest" \
  -e "${SSH_CMD}" \
  "${SOURCES[@]}" "${REMOTE}:${REMOTE_DIR}/${STAMP}.incomplete/"

# Mark the snapshot complete, point "latest" at it and remove old snapshots.
${SSH_CMD} "${REMOTE}" "cd '${REMOTE_DIR}' \
  && mv '${STAMP}.incomplete' '${STAMP}' \
  && touch '${STAMP}' \
  && ln -sfn '${STAMP}' latest \
  && find . -mindepth 1 -maxdepth 1 -type d -name '20??-??-??_????' -mtime +${KEEP_DAYS} -exec rm -rf -- {} +"

echo "Backup ${STAMP} completed"

Replace your_backup_server_ip and adjust SOURCES to the directories you care about. If the server runs a database, dump it to a directory first and add that directory to SOURCES; copying live database files does not give a usable backup.

Make the script executable and readable only by root:

sudo chmod 700 /usr/local/sbin/rsync-backup

Run it by hand the first time:

sudo /usr/local/sbin/rsync-backup

On the very first run there is no previous snapshot, so rsync prints a warning that the --link-dest directory does not exist. That is expected. The script ends with:

Backup 2026-09-25_1012 completed

Step 5 - Verifying the snapshots

Run the script a second time, then list the snapshots on the backup server:

sudo ls -l /srv/backups/web01
drwxr-xr-x 5 rbackup rbackup 4096 Sep 25 10:12 2026-09-25_1012
drwxr-xr-x 5 rbackup rbackup 4096 Sep 25 10:15 2026-09-25_1015
lrwxrwxrwx 1 rbackup rbackup   15 Sep 25 10:15 latest -> 2026-09-25_1015

Replace web01 with the short hostname of your source server. To confirm hard linking works, ask du for the size of both snapshots in one call. du counts each hard-linked file only once, so the second snapshot should be tiny:

sudo du -sh /srv/backups/web01/2026-09-25_*
1.8G	/srv/backups/web01/2026-09-25_1012
2.4M	/srv/backups/web01/2026-09-25_1015

You can also check that the original ownership was saved as extended attributes:

sudo getfattr -d /srv/backups/web01/latest/etc/shadow
# file: srv/backups/web01/latest/etc/shadow
user.rsync.%stat="100640 0,0 0:42"

The value records the original mode (640) and owner (root:shadow, IDs 0:42). If getfattr is missing, install it with sudo apt install attr.

Step 6 - Scheduling the backup with a systemd timer

A systemd timer is more reliable than cron for this job: the output goes to the journal, Persistent=true runs a missed backup after the server was powered off, and you can check the last result with systemctl.

On the source server, create the service unit:

sudo nano /etc/systemd/system/rsync-backup.service
[Unit]
Description=rsync snapshot backup to the backup server
Wants=network-online.target
After=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/rsync-backup
Nice=10
IOSchedulingClass=idle

Create the timer unit:

sudo nano /etc/systemd/system/rsync-backup.timer
[Unit]
Description=Daily rsync snapshot backup

[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=15m
Persistent=true

[Install]
WantedBy=timers.target

Reload systemd and enable the timer:

sudo systemctl daemon-reload
sudo systemctl enable --now rsync-backup.timer

Confirm the next run is scheduled:

systemctl list-timers rsync-backup.timer
NEXT                        LEFT     LAST PASSED UNIT               ACTIVATES
Fri 2026-09-26 02:38:12 UTC 16h left -    -      rsync-backup.timer rsync-backup.service

Trigger one run through systemd to be sure it works in that environment, then read the log:

sudo systemctl start rsync-backup.service
journalctl -u rsync-backup.service -n 20 --no-pager

The last lines should include Backup ... completed and Finished rsync-backup.service. If the job fails, systemctl status rsync-backup.service shows failed with the exit code.

Step 7 - Restoring files

Restores are pulls from the backup server, run as root on the source server, with the same --fake-super option so ownership and permissions are put back.

First restore into a temporary directory and compare, so you do not overwrite anything by accident. This restores /etc/nginx from the latest snapshot:

sudo rsync -aHAX --numeric-ids \
  --rsync-path="rsync --fake-super" \
  -e "ssh -i /root/.ssh/backup_ed25519" \
  rbackup@your_backup_server_ip:/srv/backups/web01/latest/etc/nginx/ /tmp/restore-nginx/

Check that the files match the live copy and that ownership is correct:

sudo diff -r /etc/nginx /tmp/restore-nginx && echo "identical"
sudo ls -l /tmp/restore-nginx/nginx.conf
identical
-rw-r--r-- 1 root root 1447 Apr  8  2024 /tmp/restore-nginx/nginx.conf

To recover an older version, replace latest with a snapshot name such as 2026-09-20_0231. To restore in place, point the destination at the real directory and run it with --dry-run first.

Troubleshooting

  • Permission denied (publickey): check that /home/rbackup/.ssh is 700, authorized_keys is 600, both are owned by rbackup, and the key is on a single line.
  • Host key verification failed in the journal: root never accepted the backup server's host key. Run the ssh test from Step 3 with sudo.
  • rsync: [receiver] ... Operation not supported (95) on extended attributes: the backup filesystem does not support user extended attributes. Use ext4 or XFS for /srv/backups.
  • Every snapshot uses full disk space: --link-dest did not find the previous snapshot. Check that the latest symlink exists on the backup server and that the source list and options have not changed between runs.
  • Exit code 24 (some files vanished before they could be transferred): files were deleted during the backup, which is normal for temporary files. Exclude those paths if it happens every night.

Conclusion

You now have daily, browsable snapshots of your server on a second machine, with 14 days of history that costs little more than one full copy, plus a tested restore procedure. rsync does not encrypt data at rest or deduplicate within files, so for backups to untrusted storage consider BorgBackup or restic. To see how this copy fits a complete plan, read the 3-2-1 backup rule.