
A backup you can’t restore is just a pile of files. This rsync over SSH backup tutorial shows how to build incremental, offsite backups for websites on a VPS or dedicated server. It also shows how to prove the backups work with a repeatable restore test.
You’ll use SSH keys, snapshot-style hard links for fast point-in-time “daily” copies, and a retention job that keeps disk usage sane.
Examples use Ubuntu Server 24.04/26.04. The same layout works on Debian 12/13, AlmaLinux, Rocky Linux, and CentOS Stream. Expect minor package differences.
What you’re building (and why it’s different from “just run rsync”)
A plain rsync job to a second machine creates a mirror. Mirrors are convenient, but they aren’t protective.
If ransomware encrypts your web root, rsync will sync the encrypted files too. That can overwrite your “backup” with junk.
The goal here is a backup chain with three clear properties:
- Offsite: backups land on a separate VPS/storage server.
- Incremental snapshots: every run writes a new timestamped directory, hard-linking unchanged files (fast and space-efficient).
- Restore-first validation: a scheduled test restore to a staging path plus quick integrity checks.
If you’d rather have someone else handle OS updates, monitoring, and backup hygiene, consider managed VPS hosting from HostMyCode. If you want DIY control with predictable performance, start with a HostMyCode VPS.
Prerequisites checklist (5 minutes)
- Source server (your web server): Ubuntu/Debian/AlmaLinux/Rocky; SSH access with sudo.
- Backup server (offsite): separate VPS/dedicated server with enough disk.
- TCP/22 open between source → backup (ideally allowlisted by IP).
- Estimate storage: take current used space × retention factor. Snapshot hard links reduce growth. Upload churn and logs still add up.
Before you build anything, write a one-page restore runbook. If you don’t have one yet, use this as a model: restore-first disaster recovery runbook.
Step 1: Prepare the backup server (user, directories, permissions)
On the backup server, create a dedicated user and destination directory. Keep backups out of /root. Don’t reuse this account for anything else.
sudo adduser --disabled-password --gecos "" backup
sudo mkdir -p /srv/backups
sudo chown backup:backup /srv/backups
sudo chmod 750 /srv/backups
Create a per-host folder. This keeps one backup box usable for multiple sources.
sudo -u backup mkdir -p /srv/backups/web01.example.com
Filesystem note: hard-link snapshots require a filesystem that supports hard links (ext4, xfs). Don’t build the snapshot tree on an object-storage mount.
Use local disk, then replicate separately if needed.
Step 2: Create an SSH key on the source (no passwords, no agent needed)
On the source server, generate a dedicated key used only for backups. Keeping it separate from your admin keys also makes revocation simple.
sudo -i
ssh-keygen -t ed25519 -a 64 -f /root/.ssh/id_ed25519_rsync_backup -C "rsync-backup-web01"
Copy the public key to the backup user:
ssh-copy-id -i /root/.ssh/id_ed25519_rsync_backup.pub backup@BACKUP_SERVER_IP
Quick connectivity test:
ssh -i /root/.ssh/id_ed25519_rsync_backup backup@BACKUP_SERVER_IP 'hostname && id'
If you want to tighten admin access patterns too, use a jump host in front of private servers. You can also keep the backup box off the public internet.
This guide shows a clean model: SSH jump host setup guide.
Step 3: Lock down the backup key (forced command + restricted permissions)
This step is optional. It’s also one of the best safety upgrades you can add.
You’ll limit what that SSH key is allowed to do on the backup server.
On the backup server, edit the backup user’s authorized_keys:
sudo -u backup nano /home/backup/.ssh/authorized_keys
Prepend options to the key line. Replace web01.example.com and the source IP.
from="SOURCE_SERVER_IP",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,command="/usr/bin/rrsync -ro /srv/backups/web01.example.com" ssh-ed25519 AAAA... rsync-backup-web01
Install rrsync (a restricted rsync wrapper). On Ubuntu/Debian it ships with rsync in documentation tools.
The simplest approach is to install rsync and copy the script:
sudo apt-get update
sudo apt-get install -y rsync
sudo cp /usr/share/doc/rsync/scripts/rrsync /usr/bin/rrsync
sudo chmod 755 /usr/bin/rrsync
On RHEL-family systems, you may need to locate rrsync in rsync docs or package it separately. If you can’t get it, skip the forced command.
Rely on IP restriction plus a dedicated backup user.
Step 4: Create a snapshot-style rsync script (incremental points-in-time)
On the source server, create a script at /usr/local/sbin/rsync-snap-backup.sh. Each run produces:
.../snapshots/2026-07-15_020000/(a new timestamped snapshot).../snapshots/latestsymlink to the newest snapshot- hard links to unchanged files via
--link-dest
sudo nano /usr/local/sbin/rsync-snap-backup.sh
#!/usr/bin/env bash
set -euo pipefail
# ---- CONFIG ----
BACKUP_HOST="BACKUP_SERVER_IP"
BACKUP_USER="backup"
BACKUP_ROOT="/srv/backups/web01.example.com"
KEY="/root/.ssh/id_ed25519_rsync_backup"
# What to back up (adjust paths)
SOURCES=(
"/var/www/"
"/etc/nginx/"
"/etc/apache2/"
"/etc/letsencrypt/"
)
# Excludes: cache, tmp, large logs, node_modules, etc.
EXCLUDES=(
"--exclude=/var/www/*/wp-content/cache/"
"--exclude=/var/www/*/wp-content/uploads/cache/"
"--exclude=*.log"
"--exclude=node_modules/"
"--exclude=.git/"
"--exclude=/var/www/*/storage/framework/cache/"
"--exclude=/var/www/*/storage/logs/"
)
TS=$(date +"%F_%H%M%S")
REMOTE_SNAP="$BACKUP_ROOT/snapshots/$TS"
REMOTE_LATEST="$BACKUP_ROOT/snapshots/latest"
SSH_OPTS=(
"-i" "$KEY"
"-o" "BatchMode=yes"
"-o" "StrictHostKeyChecking=accept-new"
"-o" "ServerAliveInterval=30"
"-o" "ServerAliveCountMax=3"
)
# ---- PREP ----
ssh "${SSH_OPTS[@]}" "$BACKUP_USER@$BACKUP_HOST" "mkdir -p '$REMOTE_SNAP' '$BACKUP_ROOT'/logs '$BACKUP_ROOT'/meta"
# Determine link-dest (previous snapshot)
LINK_DEST=""
if ssh "${SSH_OPTS[@]}" "$BACKUP_USER@$BACKUP_HOST" "test -L '$REMOTE_LATEST'"; then
PREV=$(ssh "${SSH_OPTS[@]}" "$BACKUP_USER@$BACKUP_HOST" "readlink -f '$REMOTE_LATEST'")
LINK_DEST="--link-dest=$PREV"
fi
# ---- RSYNC ----
# -aHAX helps preserve permissions/links/xattrs; drop -A/-X if your environment doesn't need them.
# --numeric-ids avoids UID/GID mapping surprises.
# --delete keeps snapshots clean relative to source.
# --partial/--partial-dir helps resumes.
LOG_LOCAL="/var/log/rsync-snap-backup.log"
rsync -aHAX --numeric-ids --delete --delete-delay --info=stats2,progress2 \
--partial --partial-dir=.rsync-partial \
"${EXCLUDES[@]}" \
$LINK_DEST \
-e "ssh ${SSH_OPTS[*]}" \
"${SOURCES[@]}" \
"$BACKUP_USER@$BACKUP_HOST:$REMOTE_SNAP/" | tee -a "$LOG_LOCAL"
# Update latest symlink atomically
ssh "${SSH_OPTS[@]}" "$BACKUP_USER@$BACKUP_HOST" "ln -sfn '$REMOTE_SNAP' '$REMOTE_LATEST'"
# Write basic metadata
sha=$(sha256sum "$LOG_LOCAL" | awk '{print $1}')
ssh "${SSH_OPTS[@]}" "$BACKUP_USER@$BACKUP_HOST" "printf '%s\n' '$TS $sha' >> '$BACKUP_ROOT/meta/backup-runs.txt'"
echo "Backup snapshot created: $TS"
sudo chmod 750 /usr/local/sbin/rsync-snap-backup.sh
Practical pitfall: if your apps constantly write sessions, caches, or generated files, snapshots will grow quickly. Tighten your excludes.
Move truly disposable paths to tmpfs where it makes sense.
Step 5: Add retention (keep 7 daily, 4 weekly, 6 monthly)
Snapshots don’t clean up after themselves. Without pruning, disk usage will creep until it becomes an outage.
Create /usr/local/sbin/rsync-snap-prune.sh on the source server. It deletes snapshots on the backup server via SSH.
sudo nano /usr/local/sbin/rsync-snap-prune.sh
#!/usr/bin/env bash
set -euo pipefail
BACKUP_HOST="BACKUP_SERVER_IP"
BACKUP_USER="backup"
BACKUP_ROOT="/srv/backups/web01.example.com"
KEY="/root/.ssh/id_ed25519_rsync_backup"
SSH_OPTS=("-i" "$KEY" "-o" "BatchMode=yes")
# Keep:
# - last 7 snapshots (daily)
# - plus the newest snapshot from each of the last 4 ISO weeks
# - plus the newest snapshot from each of the last 6 months
ssh "${SSH_OPTS[@]}" "$BACKUP_USER@$BACKUP_HOST" bash <<'EOF'
set -euo pipefail
ROOT="/srv/backups/web01.example.com/snapshots"
cd "$ROOT"
# List snapshots (exclude 'latest') sorted oldest->newest
mapfile -t snaps < <(ls -1 | grep -v '^latest$' | sort)
# Helper arrays of snapshots to keep
keep=()
# Keep last 7
if ((${#snaps[@]} > 0)); then
for s in "${snaps[@]: -7}"; do keep+=("$s"); done
fi
# Keep newest per week (last 4 weeks)
for w in $(date -d "-0 week" +%G-W%V) $(date -d "-1 week" +%G-W%V) $(date -d "-2 week" +%G-W%V) $(date -d "-3 week" +%G-W%V); do
# snapshots format: YYYY-MM-DD_HHMMSS
cand=$(printf "%s\n" "${snaps[@]}" | awk -v w="$w" 'BEGIN{FS="[_-]"} {cmd="date -d \""$1"-"$2"-"$3"\" +%G-W%V"; cmd|getline ww; close(cmd); if(ww==w) print $0}' | tail -n 1)
if [[ -n "${cand:-}" ]]; then keep+=("$cand"); fi
done
# Keep newest per month (last 6 months)
for m in 0 1 2 3 4 5; do
ym=$(date -d "-$m month" +%Y-%m)
cand=$(printf "%s\n" "${snaps[@]}" | grep -E "^${ym}-" | tail -n 1)
if [[ -n "${cand:-}" ]]; then keep+=("$cand"); fi
done
# Unique keep list
mapfile -t keep_u < <(printf "%s\n" "${keep[@]}" | sort -u)
# Delete everything else
for s in "${snaps[@]}"; do
if ! printf "%s\n" "${keep_u[@]}" | grep -qx "$s"; then
echo "Deleting snapshot: $s"
rm -rf -- "$ROOT/$s"
fi
done
EOF
sudo chmod 750 /usr/local/sbin/rsync-snap-prune.sh
This retention script keeps the logic readable and easy to audit. If you want a purpose-built tool with a cleaner policy engine, compare this with HostMyCode’s restic approach in this incremental backup tutorial using restic + S3.
Stick with rsync when you want straightforward file-level restores and minimal moving parts.
Step 6: Schedule it with systemd timers (clean logs, no cron surprises)
Cron is fine. systemd timers are easier to inspect.
You can see last-run time, exit status, and logs without guessing.
Create a service unit:
sudo nano /etc/systemd/system/rsync-snap-backup.service
[Unit]
Description=Rsync snapshot backup to offsite server
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/rsync-snap-backup.sh
Create a timer (runs daily at 02:00):
sudo nano /etc/systemd/system/rsync-snap-backup.timer
[Unit]
Description=Daily rsync snapshot backup timer
[Timer]
OnCalendar=*-*-* 02:00:00
Persistent=true
RandomizedDelaySec=300
[Install]
WantedBy=timers.target
Add a weekly prune timer:
sudo nano /etc/systemd/system/rsync-snap-prune.service
[Unit]
Description=Prune rsync snapshot backups on offsite server
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/rsync-snap-prune.sh
sudo nano /etc/systemd/system/rsync-snap-prune.timer
[Unit]
Description=Weekly prune timer for rsync snapshots
[Timer]
OnCalendar=Sun *-*-* 03:30:00
Persistent=true
RandomizedDelaySec=600
[Install]
WantedBy=timers.target
Enable and start:
sudo systemctl daemon-reload
sudo systemctl enable --now rsync-snap-backup.timer
sudo systemctl enable --now rsync-snap-prune.timer
systemctl list-timers | grep rsync-snap
Check logs after the first run:
sudo journalctl -u rsync-snap-backup.service -n 200 --no-pager
Step 7: Make it consistent during site changes (maintenance mode + database notes)
Rsync backs up files. That works well for static sites and server configuration.
Dynamic apps (WordPress, WooCommerce, Laravel) also need database backups. Files alone won’t capture database state.
Two workable approaches:
- File-only snapshots (this tutorial) plus a separate database dump job stored alongside the snapshot.
- Short maintenance window for low-traffic sites: enable maintenance mode, dump the DB, then run rsync.
Here’s the minimal pattern: write DB dumps into a predictable folder (for example, /var/backups/site-dumps). Then let rsync pick them up.
Example for WordPress on a single-site VPS:
sudo mkdir -p /var/backups/site-dumps
sudo chmod 750 /var/backups/site-dumps
Add that folder to SOURCES and rotate dumps by date. Keep dump filenames stable per run.
Stable names help rsync de-duplicate efficiently.
Step 8: Run a real restore test (the part most teams skip)
You don’t need a full staging stack to validate backups. You need a repeatable test that answers one question.
“Can I pull a snapshot back, and does it look sane?”
On the source server, create a restore target:
sudo mkdir -p /root/restore-tests
sudo chmod 700 /root/restore-tests
Pick a snapshot and pull it back (from backup server → source server):
SNAP="2026-07-15_020000"
rsync -aHAX -e "ssh -i /root/.ssh/id_ed25519_rsync_backup" \
backup@BACKUP_SERVER_IP:/srv/backups/web01.example.com/snapshots/$SNAP/ \
/root/restore-tests/$SNAP/
Quick checks you can automate:
- Config presence: Nginx/Apache vhost files exist and aren’t empty.
- WordPress sanity:
wp-config.phpexists; uploads folder is non-zero size. - Spot-check permissions: web root isn’t owned by random UIDs.
# Example sanity checks
test -s /root/restore-tests/$SNAP/etc/nginx/nginx.conf
find /root/restore-tests/$SNAP/var/www -maxdepth 3 -name wp-config.php -print
sudo du -sh /root/restore-tests/$SNAP/var/www | head
If you want a fuller “restore and bring online” plan (web + config + SSL), pair this with: VPS backup restore tutorial.
Step 9: Troubleshoot common rsync backup failures
Rsync is pretty deterministic. When something breaks, it usually fits one of these patterns.
“Permission denied” or ownership looks wrong
- Run the backup as root (or ensure your backup user can read all required paths).
- Use
--numeric-idsto preserve UIDs/GIDs instead of mapping names. - If backing up cPanel-style home directories, confirm file permissions haven’t been damaged by malware cleanup.
Backup server fills up unexpectedly
Start with a fast disk usage view on the backup server:
sudo du -sh /srv/backups/web01.example.com/snapshots/* | sort -h | tail -n 15
If you’re stuck in a “no space left” loop, use a production-safe cleanup process: VPS disk space troubleshooting tutorial.
Backups are slow or time out
- Put the backup server in a region with strong network performance to your source.
- Exclude cache and volatile directories. They change constantly and waste bandwidth.
- Use
--compress(-z) only when CPU is cheap and bandwidth is tight; otherwise it can slow things down.
Rsync deletes too much (bad path or typo)
Any time you use --delete, run a dry run first:
rsync -aHAX --delete --dry-run -e "ssh -i /root/.ssh/id_ed25519_rsync_backup" \
/var/www/ backup@BACKUP_SERVER_IP:/srv/backups/web01.example.com/snapshots/TEST/
Also confirm the remote path includes the expected host folder. One missing directory can turn “delete old files” into “delete everything.”
Step 10: Hardening notes for backup traffic (quick wins)
- Allowlist SSH: only permit the source server IP to SSH into the backup server (at firewall level).
- Separate disks: if possible, keep backup storage on a separate volume mounted at
/srv/backups. - Monitor: alert when backups stop running or when disk usage crosses a threshold.
For alerts that catch “the timer stopped” failures, monitor the systemd timer state and backup disk usage.
This guide gives you a solid baseline: server monitoring tutorial.
Summary: your minimum viable offsite backup setup
- Dedicated backup server user and folder structure
- Rsync snapshots using
--link-dest - Retention pruning you can explain in one paragraph
- Restore tests that run on schedule and produce evidence
If you run multiple sites, reseller hosting workloads, or client WordPress installs, this approach scales cleanly.
Give each host its own snapshot tree and retention rules.
If you don’t want to maintain the OS and backup plumbing yourself, use managed VPS hosting with HostMyCode. Keep your time for site work, not emergency restores.
If you want dependable offsite backups for production sites, start with a VPS that delivers stable I/O and predictable networking. Run your web stack on a HostMyCode VPS, then add a second small VPS as a dedicated backup target. If you want your backup plan reviewed and kept healthy over time, choose managed VPS hosting.
FAQ
Is rsync over SSH safe enough for offsite backups?
Yes—if you use key-based auth, restrict the key (forced command and source IP), and don’t reuse that key for admin logins. Encrypt-at-rest is a separate choice. rsync doesn’t encrypt stored data.
Do hard-link snapshots waste disk space?
They’re efficient for mostly-unchanged trees. Unchanged files are hard-linked, so you don’t store duplicates. Growth usually comes from file churn (uploads, caches, logs) and your retention settings.
Should I run backups from root?
Often, yes. Web stacks scatter readable files across /etc, TLS paths, and user homes. If you use a non-root backup user, plan to manage permissions and ACLs carefully.
How do I know my backup is restorable without a full staging site?
Automate a restore into a local folder and validate the basics. Check that key config files exist, permissions look sane, and directories have expected sizes. Add a manual “full restore rehearsal” quarterly.
How long should I keep snapshots?
For many small hosting workloads, 7 daily + 4 weekly + 6 monthly is a reasonable 2026 baseline. Tighten it if disk is expensive. Extend it if compliance or client requirements demand longer retention.