VPS Backup Strategy: A Complete Guide to Not Losing Your Data

Published on | Updated

How to build a backup strategy for your VPS: automated database and file backups, off-site copies with rclone, restore tests and the 3-2-1 backup rule.

A technician with a tablet in a data center aisle between server racks

The question isn't if you'll need a backup. It's when. Your backup strategy decides whether a deleted file, a broken update or a lost server ends with a restore or with lost data. On a self-managed VPS that strategy is yours to build: what to copy, a script that runs every night, a copy off the server, a restore test and storage that fits your budget.

The 3-2-1 Rule

The UK's National Cyber Security Centre sums the rule up as "at least 3 copies, on 2 devices, and 1 offsite":

RuleMeaningExample
3 copiesKeep three copies of dataOriginal + 2 backups
2 devicesStore the copies on two different devicesVPS disk + object storage
1 off-siteAt least one copy off-siteRemote server or cloud

What to Back Up

DataPriorityFrequency
DatabasesCriticalEvery 6 hours
Application codeHighDaily (or use Git)
Configuration filesHighAfter every change
User uploadsCriticalDaily
SSL certificatesMediumAfter renewal
Cron jobs and scriptsMediumAfter changes

Don't Back Up

  • OS files (reinstall from scratch faster than restoring)
  • Package cache (/var/cache/apt/)
  • Temporary files (/tmp/)
  • Log files (unless needed for compliance)

Automated Backup Script

This script dumps each database to its own file, archives the web files and the configuration, and removes local copies after a week. Set DATABASES to the names of your databases.

#!/bin/bash
# /opt/backups/backup.sh

set -e

DATE=$(date +%Y%m%d-%H%M)
BACKUP_DIR="/opt/backups/local"
RETENTION_DAYS=7
DATABASES="app_db"   # your database names, separated by spaces

mkdir -p "$BACKUP_DIR"

echo "Starting backup: $DATE"

# Database backup: one dump per database
echo "Backing up databases..."
for DB in $DATABASES; do
  mysqldump --single-transaction "$DB" > "$BACKUP_DIR/db-$DB-$DATE.sql"
done

# Web files backup
echo "Backing up web files..."
tar czf "$BACKUP_DIR/www-$DATE.tar.gz" \
  --exclude='node_modules' \
  --exclude='.git' \
  /var/www/

# Configuration backup: keep only the paths that exist on your server
echo "Backing up configurations..."
tar czf "$BACKUP_DIR/config-$DATE.tar.gz" \
  /etc/nginx/ \
  /etc/php/ \
  /etc/mysql/ \
  /etc/letsencrypt/ \
  /etc/crontab

# Remove old local copies
echo "Cleaning old backups..."
find "$BACKUP_DIR" -name "*.tar.gz" -mtime +$RETENTION_DAYS -delete
find "$BACKUP_DIR" -name "*.sql" -mtime +$RETENTION_DAYS -delete

echo "Backup completed: $DATE"

What the details do:

  • --single-transaction dumps a consistent state without blocking your application, but only for InnoDB tables. The MySQL manual warns that MyISAM or MEMORY tables can still change during the dump.
  • One dump per database. Without --databases, a dump of one database contains no CREATE DATABASE or USE statements, so you can load it into any database name you choose. The restore test below depends on that. An --all-databases dump writes those statements for every database, so it always loads back into the original names.
  • MariaDB calls the tool mariadb-dump; mysqldump still works through a symlink on Linux, but MariaDB has marked that name deprecated since 11.0, so on MariaDB 11 or newer write mariadb-dump in the script. A per-database dump does not hold your users and grants: our MariaDB and MySQL backup guide covers those.
  • set -e stops the script at the first command that fails, so a failed dump ends the run with an error in the log instead of passing unnoticed. GNU tar exits with status 1 when a file changed while it was archived and with 2 on a fatal error, such as a path that doesn't exist, so trim the configuration list to what your server has.
  • -mtime +7 matches files at least eight days old: find ignores the fractional part when it counts 24-hour periods, so +7 means more than seven whole periods.

Schedule it from root's crontab, so it can read /etc and write to /var/log:

sudo chmod +x /opt/backups/backup.sh
sudo crontab -e
# Run daily at 2 AM
0 2 * * * /opt/backups/backup.sh >> /var/log/backup.log 2>&1

Off-Site Backup with rclone

# Install rclone
sudo apt install rclone
sudo rclone config  # Set up your remote storage

Run rclone config as root: rclone keeps its configuration per user (in ~/.config/rclone/rclone.conf), and the cron job above runs as root. Ubuntu 24.04 and Debian 12 ship rclone 1.60.1. rclone's own install page says distribution packages are often out of date and recommends its other install methods where possible.

Copy to Cloud Storage

# Copy local backups to the remote; nothing is deleted there
sudo rclone copy /opt/backups/local remote:vps-backups/

# Or add this as the last line of backup.sh (it already runs as root)
rclone copy "$BACKUP_DIR" remote:vps-backups/

Use copy, not sync. rclone sync makes the destination match the source, deleting files there if necessary, and rclone's docs warn that this can cause data loss. With sync, your off-site copy would lose every file the local cleanup removes, and an emptied local folder would empty the remote as well. rclone copy skips identical files and never deletes from the destination.

Keep the off-site copies longer than the local ones and clean them up on their own schedule:

# Check what would be deleted, then delete off-site copies older than 30 days
sudo rclone --dry-run --min-age 30d delete remote:vps-backups/
sudo rclone --min-age 30d delete remote:vps-backups/

rclone delete obeys filters, and --min-age 30d limits it to files at least 30 days old. Database dumps hold your users' data, so consider encrypting them before they leave the server: an rclone crypt remote wraps another remote and encrypts what you store through it.

To keep encrypted, versioned copies on a second server you control instead, follow our restic over SSH guide.

Where to Store the Copies

Prices in US dollars as each vendor's own pricing page listed them on 31 October 2026. Prices change, so check the page again before you choose.

StoragePrice (checked 31 October 2026)Also on the pricing pagerclone backend
Backblaze B2$6.95 per TB per month, pay as you goNo minimum storage duration fees; egress free up to 3x your average monthly storage, then $0.01 per GBBackblaze B2
Amazon S3 Standard$0.023 per GB per month for the first 50 TB in EU (Ireland), $0.0245 in EU (Frankfurt)No minimum charge; requests and data transfer are billed separatelyAmazon S3 (provider AWS)
Wasabi$7.99 per TB per month, Pay as You GoMinimum monthly charge tied to 1 TB; 90-day minimum storage duration; no fees for egress or API requestsAmazon S3 (provider Wasabi)
Google Cloud Storage Standard$0.020 per GiB per month in Netherlands (europe-west4), $0.023 in Frankfurt (europe-west3)Operations and network usage are billed separatelyGoogle Cloud Storage
Another server you controlThat server's priceEncrypted, versioned copies with restic over SFTPSFTP

Two things move the real bill. Restores download data: Amazon S3 bills data transfer out to the internet beyond the first 100 GB a month (counted across all AWS services), Google Cloud Storage bills network usage, and B2's free egress stops at three times your average stored data. And Wasabi's Pay as You Go plan has a 90-day minimum storage duration, so check how that fits a short retention before you pick it. To estimate how much space your backups take, see our backup storage guide.

Backup Verification

A backup that doesn't restore is not a backup. This script loads the newest dump of one database into a scratch database and reads the newest web archive from start to end:

#!/bin/bash
# /opt/backups/verify.sh

set -e

BACKUP_DIR="/opt/backups/local"
DB="app_db"

DUMP=$(ls -t "$BACKUP_DIR"/db-"$DB"-*.sql | head -n 1)
WWW=$(ls -t "$BACKUP_DIR"/www-*.tar.gz | head -n 1)

# Test database restore into a scratch database
mysql -e "DROP DATABASE IF EXISTS backup_test; CREATE DATABASE backup_test;"
mysql backup_test < "$DUMP"
TABLES=$(mysql -N -e "SELECT COUNT(*) FROM information_schema.tables WHERE table_schema='backup_test';")
echo "Restored $TABLES tables from $DUMP"
mysql -e "DROP DATABASE backup_test;"

# Test archive integrity: tar lists the whole archive and fails on damage
tar tzf "$WWW" > /dev/null
echo "Archive integrity OK: $WWW"

Never point a test like this at an --all-databases dump on a live server. That file creates and selects each original database with CREATE DATABASE and USE, so the "test" would write old data into your real databases instead of backup_test. The scratch database also needs free disk space for a second copy of the data. For a full drill on a spare VPS, with the app started and the time measured, see our restore test guide.

Backup Schedule Recommendation

Data TypeFrequencyRetentionLocation
DatabaseEvery 6 hours14 daysLocal + off-site
Web filesDaily7 daysLocal + off-site
ConfigsAfter change30 daysLocal + off-site
Server-level backupWeekly4 weeksThrough your provider

What Decides Your Recovery Time

ScenarioRecovery FromWhat sets the time
Deleted fileFile backupFinding the right archive and extracting one path
Corrupted databaseDatabase dumpThe size of the dump and how fast it imports
Hacked serverClean reinstall + backups from before the break-inReinstalling, hardening and finding a backup you trust
Hardware failureOff-site backup + new VPSHow much you download and how fast you rebuild the stack

The minutes depend on your data and your server, so time your own restore test and write the number down: that is your real recovery time. To turn it into recovery targets, with account access, contacts and the first hour of an outage, see our disaster recovery checklist.

On a Space-Node VPS, backups are not included in the plan price. For a server-level backup, open a support ticket: support tells you the backup option, how long backups are kept, how a restore works and what it costs before anything is set up. Keep the off-site copy from this guide either way.

Sources

All opened on 31 October 2026: