
A backup you haven’t restored is just a hope with a billing cycle. This rsync backup tutorial shows a repeatable way to back up a VPS or dedicated server over SSH. You’ll keep multiple days of snapshots without duplicating unchanged data. You’ll also prove you can actually restore.
This is a push setup. The source server (web/mail/app) connects to a separate backup server over SSH.
The steps work on Ubuntu, Debian, AlmaLinux, Rocky Linux, and CentOS Stream. They still fit if you later split services across multiple nodes.
What you’ll build (and what this does better than ad-hoc tar files)
- Encrypted transfer over SSH with a dedicated, locked-down key.
- Incremental daily snapshots using
--link-dest(hard links) so unchanged files don’t eat disk. - Rotation (keep 7–30 days, delete safely).
- Restore test that checks files, ownership, and permissions.
For WordPress, you’ll usually back up /var/www (or your vhost roots) plus selected configuration.
On cPanel/WHM, WHM backups remain the default for account-level restores. rsync still helps for server config, logs, and a “break glass” recovery path.
Prerequisites and sizing checklist
Before you touch rsync, confirm what you plan to restore. Then confirm the backup target can hold it.
- Two Linux servers: a production VPS/dedicated server + a backup VPS/dedicated server.
- Network access: SSH (port 22 by default) from source to backup.
- Disk sizing rule: start with
1.5×to2×your used data on the source if you keep 7–14 daily snapshots. If your site churns a lot (uploads, caches), plan higher. - Time window: schedule backups during low traffic and avoid overlap with heavy cron jobs.
If you don’t already have a separate backup host, add a second VPS.
For clean isolation and predictable performance, a HostMyCode VPS is a sensible backup target.
If you want the platform side handled on the production node (updates, baseline security), use managed VPS hosting there and keep backups independent.
Step 1: Prepare the backup server (create a dedicated user and directory)
On the backup server, create a non-root user. Give it a dedicated destination directory.
Keep permissions tight. If the production server is compromised, it shouldn’t roam around the backup box.
sudo adduser --disabled-password --gecos "" backup
sudo mkdir -p /backups/vps1
sudo chown -R backup:backup /backups/vps1
sudo chmod 750 /backups /backups/vps1
Optional (but usually worth it): put backups on a separate volume (LVM or an extra disk) mounted at /backups.
This keeps backups from competing with the OS disk. It also simplifies capacity planning and recovery.
Step 2: Create a locked-down SSH key for rsync (no interactive shell)
On the source server, generate a dedicated key pair for backups. Don’t reuse your admin key.
sudo ssh-keygen -t ed25519 -a 64 -f /root/.ssh/rsync_backup -C "rsync-backup"
Copy the public key to the backup server (as the backup user):
sudo ssh-copy-id -i /root/.ssh/rsync_backup.pub backup@BACKUP_SERVER_IP
Now restrict that key on the backup server. The goal is simple: it can only do what you intend.
Edit:
sudo -u backup nano /home/backup/.ssh/authorized_keys
Prepend a forced command and restrictions to the key line. Replace /backups/vps1 with your path:
command="rsync --server --daemon .",no-agent-forwarding,no-port-forwarding,no-pty,no-user-rc,no-X11-forwarding ssh-ed25519 AAAA... rsync-backup
Reality check: forced-command setups are easy to misconfigure.
A safer operational pattern is simpler:
- Use a dedicated user.
- Lock down its permissions.
- Restrict inbound SSH on the backup server to the source IP at the firewall.
If you also want a tighter SSH baseline on production, pair this with SSH key setup guide.
Step 3: Decide what to back up (and what to exclude)
Don’t rsync the entire filesystem by reflex.
You’ll waste time copying pseudo filesystems, caches, and noisy logs. Back up what you can realistically restore.
Common hosting paths worth backing up:
/etc(service configs)/var/wwwor your vhost roots/home(if you store site files there)/root(only if you keep operational scripts there)/var/spool/cronor/etc/cron.*(cron jobs)/var/log(optional; useful for incident review, but large)
Exclude these by default:
/proc,/sys,/dev,/run/tmp,/var/tmp- Application caches (WordPress cache folders, PHP opcache files, etc.)
- Container layers if you don’t intend to restore them from rsync
If you run WordPress, decide up front how you’ll handle the database.
File backups via rsync pair well with a separate DB dump job (or a managed database).
If your priority is consistent SSL and web config, keep a reference of your TLS settings. TLS hardening tutorial is a good checklist of what to preserve.
Step 4: Build the snapshot layout on the backup server
Store backups in a predictable layout:
/backups/vps1/current/– latest snapshot/backups/vps1/daily/YYYY-MM-DD/– dated snapshots
Create directories on the backup server:
sudo -u backup mkdir -p /backups/vps1/current /backups/vps1/daily
Step 5: Write the rsync backup script (incremental via --link-dest)
On the source server, create /usr/local/sbin/rsync-backup.sh:
sudo nano /usr/local/sbin/rsync-backup.sh
Paste and adjust (set BACKUP_HOST and paths):
#!/usr/bin/env bash
set -euo pipefail
BACKUP_HOST="backup@BACKUP_SERVER_IP"
BACKUP_ROOT="/backups/vps1"
SSH_KEY="/root/.ssh/rsync_backup"
DATE="$(date -u +%F)"
SRC_PATHS=(
"/etc"
"/var/www"
"/home"
)
EXCLUDES=(
"/proc/*"
"/sys/*"
"/dev/*"
"/run/*"
"/tmp/*"
"/var/tmp/*"
"**/cache/**"
"**/wp-content/cache/**"
)
SSH_OPTS=(
"-i" "$SSH_KEY"
"-o" "BatchMode=yes"
"-o" "StrictHostKeyChecking=accept-new"
"-o" "ServerAliveInterval=30"
"-o" "ServerAliveCountMax=6"
)
# Ensure target dirs exist
ssh "${SSH_OPTS[@]}" "$BACKUP_HOST" "mkdir -p $BACKUP_ROOT/daily/$DATE $BACKUP_ROOT/current"
# If a previous snapshot exists, use it for link-dest to create an incremental snapshot
LINK_DEST_OPT=""
if ssh "${SSH_OPTS[@]}" "$BACKUP_HOST" "test -d $BACKUP_ROOT/current && [ -n \"$(ls -A $BACKUP_ROOT/current 2>/dev/null)\" ]"; then
LINK_DEST_OPT="--link-dest=$BACKUP_ROOT/current"
fi
EXCLUDE_ARGS=()
for ex in "${EXCLUDES[@]}"; do
EXCLUDE_ARGS+=("--exclude=$ex")
done
# Run rsync
rsync -aHAX --numeric-ids --delete \
--info=stats2,progress2 \
"${EXCLUDE_ARGS[@]}" \
$LINK_DEST_OPT \
-e "ssh ${SSH_OPTS[*]}" \
"${SRC_PATHS[@]}" \
"$BACKUP_HOST:$BACKUP_ROOT/daily/$DATE/"
# Update 'current' to point to today's snapshot
ssh "${SSH_OPTS[@]}" "$BACKUP_HOST" "rm -rf $BACKUP_ROOT/current && cp -al $BACKUP_ROOT/daily/$DATE $BACKUP_ROOT/current"
Make it executable:
sudo chmod 750 /usr/local/sbin/rsync-backup.sh
Why these flags:
-apreserves permissions and timestamps. Critical for web roots and system configs.-HAXpreserves hardlinks, ACLs, and xattrs (useful on modern Linux stacks).--numeric-idsavoids user/group mapping surprises across servers.--deletekeeps snapshots consistent (it deletes files removed on the source).--link-destcreates incremental snapshots without re-copying unchanged files.
Step 6: Add retention (rotate old snapshots safely)
Decide how many dailies you want. Then enforce it.
On the source server, create /usr/local/sbin/rsync-backup-rotate.sh:
sudo nano /usr/local/sbin/rsync-backup-rotate.sh
Example: keep 14 days.
#!/usr/bin/env bash
set -euo pipefail
BACKUP_HOST="backup@BACKUP_SERVER_IP"
BACKUP_ROOT="/backups/vps1"
SSH_KEY="/root/.ssh/rsync_backup"
KEEP_DAYS=14
SSH_OPTS=(
"-i" "$SSH_KEY"
"-o" "BatchMode=yes"
"-o" "StrictHostKeyChecking=accept-new"
)
# Delete snapshots older than KEEP_DAYS
ssh "${SSH_OPTS[@]}" "$BACKUP_HOST" "find $BACKUP_ROOT/daily -mindepth 1 -maxdepth 1 -type d -mtime +$KEEP_DAYS -print -exec rm -rf {} +"
Make it executable:
sudo chmod 750 /usr/local/sbin/rsync-backup-rotate.sh
Pitfall: avoid rotating by name with naive ls | head pipelines.
Use find + -mtime (as shown) or a dedicated retention tool. This makes it much harder to delete the wrong thing.
Step 7: Run the first backup and validate the snapshot
Run the first job manually. You want to see errors immediately:
sudo /usr/local/sbin/rsync-backup.sh
On the backup server, confirm a snapshot exists. Then confirm the numbers look reasonable:
sudo -u backup ls -lah /backups/vps1/daily
sudo -u backup du -sh /backups/vps1/daily/* | tail
To prove incrementals work, make a small change (create a test file) and run the job again.
The second run should finish faster. Disk growth should mostly reflect what changed.
Step 8: Schedule with systemd timers (cleaner than cron)
systemd timers make backups easier to audit.
You get status, history, and logs in one place.
Create a service on the source server:
sudo nano /etc/systemd/system/rsync-backup.service
[Unit]
Description=Nightly rsync snapshot backup
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/rsync-backup.sh
Create the timer:
sudo nano /etc/systemd/system/rsync-backup.timer
[Unit]
Description=Run rsync-backup nightly
[Timer]
OnCalendar=*-*-* 02:15:00
RandomizedDelaySec=15m
Persistent=true
[Install]
WantedBy=timers.target
Add a second service+timer for rotation. Schedule it after the backup:
sudo nano /etc/systemd/system/rsync-backup-rotate.service
[Unit]
Description=Rotate old rsync snapshots
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/rsync-backup-rotate.sh
sudo nano /etc/systemd/system/rsync-backup-rotate.timer
[Unit]
Description=Run rsync snapshot rotation
[Timer]
OnCalendar=*-*-* 04:30:00
RandomizedDelaySec=10m
Persistent=true
[Install]
WantedBy=timers.target
Enable timers:
sudo systemctl daemon-reload
sudo systemctl enable --now rsync-backup.timer rsync-backup-rotate.timer
systemctl list-timers | grep rsync-backup
If you prefer cron for compatibility, that’s fine. Keep it boring and log output.
systemd generally makes “why didn’t it run?” faster to answer.
Step 9: Add quick monitoring and alerting signals
Backups tend to fail quietly. Start with checks you’ll actually run:
- Timer status:
systemctl status rsync-backup.service - Logs:
journalctl -u rsync-backup.service --since "24 hours ago" - Backup freshness on the backup server: newest directory in
/backups/vps1/dailyshould be today (UTC).
If you already have monitoring, treat “backup completed” as a first-class signal.
The patterns in uptime monitoring tutorial map neatly to backup health checks.
Step 10: Restore test (the part people skip)
Do a restore test after the first successful week. Then repeat monthly.
You’re checking more than file presence. You’re validating permissions and ownership, too.
Example: restore a single site directory to a staging path on a test server:
# On a restore target server (or the source if you’re testing locally)
mkdir -p /restore-test
# Pull from backup snapshot (choose a date)
rsync -aHAX --numeric-ids \
-e "ssh -i /root/.ssh/rsync_backup" \
backup@BACKUP_SERVER_IP:/backups/vps1/daily/2026-09-01/var/www/example.com/ \
/restore-test/example.com/
Validate a few key items:
- Ownership:
stat -c "%U:%G %a %n" /restore-test/example.com - Symlinks: confirm expected links are preserved as links (rsync does this with
-a). - Config sanity: if you backed up
/etc/nginxor/etc/apache2, verify you can runnginx -torapachectl -tafter restoring to a test box.
If a real restore involves DNS changes, treat it as a planned cutover.
HostMyCode’s DNS cutover checklist is a solid runbook for that process.
Troubleshooting: common rsync backup failures on hosting servers
- “Permission denied” on the backup target: check
/backupspermissions and that you’re writing asbackup. Also confirm SSH is using the intended key and not another identity. - Slow transfers: make sure you’re not pulling in cache directories, and don’t compress already-compressed media. For large binaries,
rsync -zoften wastes CPU. - UID/GID mismatches: keep
--numeric-ids. If you need readable names on the backup server, mirror/etc/passwd//etc/groupfor service users—but don’t depend on name mapping for correctness. - Backups overlap and pile up: systemd timers help, but large datasets can still run long. Add a lock file if needed;
flockaround the rsync command is a simple fix. - SSH host key prompts break automation: pre-accept host keys with a manual SSH once, or use
StrictHostKeyChecking=accept-newas shown (safer than disabling checks).
Hardening checklist for a backup-by-SSH setup
- Use a dedicated backup user on the backup server (not root).
- Use a dedicated SSH key for rsync (not your admin key).
- Restrict inbound SSH to known source IPs at the firewall.
- Log and review SSH logins on the backup server (
/var/log/auth.logor journald). - Patch both machines regularly. For a safer update workflow, follow VPS patch management tutorial.
Summary: a backup routine you can trust
This rsync backup tutorial gives you a practical middle ground: encrypted transfers, multiple restore points, and efficient storage thanks to hard-linked snapshots.
The payoff is operational. You end up with a restore you’ve tested, not a pile of archives nobody has verified.
If you want a clean place to land these backups, put the target on a separate HostMyCode VPS and keep it isolated from your web stack.
If you’d rather keep production maintenance predictable, managed VPS hosting can cover patching, monitoring, and baseline hygiene without taking away your access.
If you’re building a backup routine for client sites or a business-critical WordPress server, keep the backup host separate from production. HostMyCode makes that easy with an affordable HostMyCode VPS for offsite snapshots, and managed VPS hosting if you want help maintaining the production server while keeping control.
FAQ
Is rsync good enough for VPS backups in 2026?
Yes for file-level backups, especially web roots and config. Pair it with a database dump strategy and do regular restore tests.
Should I compress rsync transfers with -z?
Usually no for hosting data sets heavy on images, ZIPs, and videos. Compression can slow the job by burning CPU. Test on your workload.
Can I back up a cPanel server with rsync instead of WHM backups?
WHM backups are still the standard for account-level restores. rsync is useful for server configs, custom scripts, and an extra recovery layer.
How many snapshots should I keep?
A common baseline is 14 daily snapshots. If you run frequent updates or accept orders, consider 30. Storage growth depends on daily change rate.
What’s the fastest way to verify my backups aren’t corrupt?
Restore a directory to a test path and compare checksums for a sample set of files. For deeper checks, run a full restore rehearsal quarterly.