A VPS backup earns its keep when the original server is unavailable. Provider snapshots help with routine recovery, but they remain inside the account and infrastructure you may be trying to escape.

This setup sends encrypted, deduplicated backups to Cloudflare R2. It covers web files, database dumps, selected system configuration, retention, integrity checks, and a staged restore process.

The goal is simple: a fresh Ubuntu server plus credentials stored somewhere outside the VPS should be enough to recover.

What you need

Gather these values before starting:

  • a name for the server, such as prod-vps-1;
  • the paths you need to protect, such as /var/www, /etc, and /home;
  • a Cloudflare R2 bucket and account ID;
  • an R2 API token limited to object read and write access for that bucket;
  • a long Restic repository password stored in a password manager away from the server;
  • working MySQL or MariaDB credentials for database dumps.

R2 uses an S3-compatible endpoint:

https://<ACCOUNT_ID>.r2.cloudflarestorage.com

Install Restic and enter a root shell

sudo apt update
sudo apt install -y restic
sudo -i

The remaining commands run inside that root shell. This keeps file ownership, environment variables, scheduled jobs, and restore commands consistent.

Ubuntu’s packaged Restic version is usually fine. Check restic version against the current releases if you need a newer feature or backend fix.

Store the repository credentials

Create a root-only configuration directory:

install -d -m 700 /root/.config/restic
nano /root/.config/restic/r2.env

Add the R2 and repository settings:

RESTIC_REPOSITORY="s3:https://<ACCOUNT_ID>.r2.cloudflarestorage.com/<BUCKET_NAME>"
RESTIC_PASSWORD_FILE="/root/.config/restic/repository-password"
AWS_ACCESS_KEY_ID="<R2_ACCESS_KEY_ID>"
AWS_SECRET_ACCESS_KEY="<R2_SECRET_ACCESS_KEY>"
RESTIC_HOST="<SERVER_NAME>"

Create the separate repository password file. It should contain only the password and a trailing newline.

nano /root/.config/restic/repository-password
chmod 600 /root/.config/restic/r2.env
chmod 600 /root/.config/restic/repository-password

Load the variables into the current root shell and initialize the repository:

set -a
source /root/.config/restic/r2.env
set +a

restic init

Save a copy of the repository password and R2 credentials outside this server. Restic cannot recover data without the repository password.

Protect the database credentials

Backing up live files from /var/lib/mysql can produce an inconsistent database copy. Create a logical SQL dump first.

--single-transaction gives InnoDB tables a consistent snapshot without a global read lock. Servers with non-transactional tables need a separate locking or maintenance plan. Newer MariaDB packages may provide mariadb-dump as the preferred command name.

Store the database credentials in a root-only option file:

nano /root/.config/restic/mysql.cnf
[client]
user=root
password=<DB_PASSWORD>
chmod 600 /root/.config/restic/mysql.cnf

MySQL’s documentation recommends a protected option file instead of placing the password on the command line. A dedicated backup account with the minimum required privileges is preferable on larger or shared systems.

Create one sequential backup script

The script below creates the database dump, confirms the dump completed, backs up the selected paths, and applies retention. Running those steps in one process avoids a fixed cron gap that may be shorter than the database dump.

nano /usr/local/sbin/restic-backup
#!/usr/bin/env bash
set -Eeuo pipefail
umask 077

set -a
source /root/.config/restic/r2.env
set +a

DUMP_DIR="/var/backups/db"
STAMP="$(date +%F-%H%M%S)"
DUMP_TMP="${DUMP_DIR}/alldb-${STAMP}.sql.tmp"
DUMP_FINAL="${DUMP_DIR}/alldb-${STAMP}.sql"

install -d -m 700 "${DUMP_DIR}"
trap 'rm -f "${DUMP_TMP}"' EXIT

mysqldump \
  --defaults-extra-file=/root/.config/restic/mysql.cnf \
  --all-databases \
  --single-transaction \
  --quick \
  --routines \
  --events \
  --triggers \
  > "${DUMP_TMP}"

mv "${DUMP_TMP}" "${DUMP_FINAL}"
find "${DUMP_DIR}" -type f -name 'alldb-*.sql' -mtime +7 -delete

BACKUP_PATHS=(
  /var/www
  /var/backups/db
  /etc
  /home
)

restic backup \
  --host "${RESTIC_HOST}" \
  --exclude=/var/cache \
  --exclude=/var/tmp \
  "${BACKUP_PATHS[@]}"

restic forget \
  --host "${RESTIC_HOST}" \
  --keep-daily 7 \
  --keep-weekly 4 \
  --keep-monthly 12 \
  --prune

Adjust BACKUP_PATHS for the actual server. Add application uploads, Compose files, service data, or other paths required to rebuild the stack. Databases outside MySQL need their own consistent dump or snapshot process.

Lock down the script:

chown root:root /usr/local/sbin/restic-backup
chmod 700 /usr/local/sbin/restic-backup

Run it once manually before scheduling it:

/usr/local/sbin/restic-backup

Schedule the backup with systemd

Create the service:

nano /etc/systemd/system/restic-backup.service
[Unit]
Description=Restic backup to Cloudflare R2
After=network-online.target mysql.service
Wants=network-online.target

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/restic-backup
Nice=10
IOSchedulingClass=best-effort
IOSchedulingPriority=7

Use After=network-online.target mariadb.service on a MariaDB host.

Create the timer:

nano /etc/systemd/system/restic-backup.timer
[Unit]
Description=Daily Restic backup timer

[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
RandomizedDelaySec=10m

[Install]
WantedBy=timers.target

Enable the timer:

systemctl daemon-reload
systemctl enable --now restic-backup.timer
systemctl list-timers restic-backup.timer

Persistent=true runs a missed job after the server returns. The randomized delay avoids starting every scheduled job at the same second.

Verify the backup and the data

Check the service result after the first scheduled or manual run:

systemctl status restic-backup.service
journalctl -u restic-backup.service --since today
restic snapshots --host "${RESTIC_HOST}"

Run a structural repository check regularly:

restic check

The default check does not read every stored pack file. Restic documents --read-data and --read-data-subset for verifying the underlying data. A monthly subset check spreads the work out:

restic check --read-data-subset=5%

Run a full restic check --read-data periodically when repository size and R2 read costs allow it.

Then test an actual restore into a temporary location:

install -d -m 700 /var/tmp/restic-restore-test
restic restore latest \
  --host "${RESTIC_HOST}" \
  --target /var/tmp/restic-restore-test \
  --include /var/backups/db

find /var/tmp/restic-restore-test/var/backups/db -maxdepth 1 -type f -ls

Inspect a restored SQL file and record the test date. Repository integrity and restore usability are separate checks.

Recover onto a fresh VPS

Start with a new Ubuntu server and enter a root shell:

sudo apt update
sudo apt install -y restic mysql-server nginx rsync
sudo -i

Recreate /root/.config/restic/r2.env and repository-password, apply mode 600, and load the environment:

set -a
source /root/.config/restic/r2.env
set +a

restic snapshots

The new machine has a different hostname, so select the original snapshot host explicitly during recovery:

install -d -m 700 /srv/restic-restore
restic restore latest \
  --host "<ORIGINAL_SERVER_NAME>" \
  --target /srv/restic-restore \
  --include /var/www \
  --include /var/backups/db \
  --include /etc \
  --include /home

This stages the files under /srv/restic-restore instead of overwriting the fresh system. Inspect the old configuration and copy only the services you intend to recreate.

For example:

rsync -aHAX /srv/restic-restore/var/www/ /var/www/
rsync -aHAX /srv/restic-restore/etc/nginx/ /etc/nginx/
nginx -t

Recreate the protected MySQL option file, then import the newest dump:

LATEST_DUMP=$(ls -1t /srv/restic-restore/var/backups/db/alldb-*.sql | head -n 1)
mysql --defaults-extra-file=/root/.config/restic/mysql.cnf < "${LATEST_DUMP}"

Application-specific recovery may also require packages, service users, permissions, environment files, DNS changes, and secrets stored outside the backup. Test those steps before an outage turns them into guesses.

Estimate the storage cost

R2 Standard pricing is currently $0.015 per GB-month. The monthly free tier includes 10 GB-month of storage, 1 million Class A operations, and 10 million Class B operations. Storing an average of 100 GB would put the storage portion around $1.35 per month after the free allowance, plus any billable operations.

Restic deduplicates unchanged data, so 12 monthly snapshots do not automatically use 12 full copies. The real bill depends on the amount of data stored, daily change rate, file count, retention, and integrity-check reads. Measure the bucket after the first month and calculate from that usage.

A backup system is complete when you can restore from it. Keep the credentials outside the VPS, review the timer, read stored data on a schedule, and stage a recovery often enough that the commands remain familiar.