The safest way to back up a Linux VPS is to send encrypted snapshots to storage outside the server every day with restic, keep a clear retention policy and test restores regularly. This guide sets that up on Debian 12/13, Ubuntu 22.04/24.04 and Rocky Linux / AlmaLinux: install restic, pick an off-site repository (S3-compatible storage, SFTP or rclone), dump databases, schedule it with a systemd timer and prove you can get your data back.
Why Your Backups Must Live Off the VPS
A copy on the same server is not a backup: whatever removes the server removes the copy too. On a JUSTG cloud VPS, several normal actions delete data permanently:
- Change location: a fresh VPS is installed with a new IP and nothing is copied. As soon as it is ready, the old VPS and all its data are deleted.
- Overdue renewal: an unpaid service is suspended on the due date; if the invoice is still unpaid 7 days after the due date, the service is cancelled and its data is deleted.
- OS reinstall wipes the disk, and an Immediate cancellation deletes the service and its data as soon as it is processed.
Add a mistyped rm -rf, a broken upgrade or a hacked server, and the rule is simple: the only backup you can rely on is one you make yourself and store somewhere else. Before any relocation, reinstall or cancellation, run a backup by hand and confirm the new snapshot exists.
Step 1: Install restic
Debian 12/13, Ubuntu 24.04 and EPEL ship recent enough versions:
# Debian 12/13, Ubuntu 24.04
apt update && apt install -y restic
restic version
# Rocky Linux / AlmaLinux 8, 9 (from EPEL)
dnf install -y epel-release && dnf install -y restic
Ubuntu 22.04 ships restic 0.12, which has no compression and cannot use the current repository format. There, install the official binary and verify its checksum:
# Official static binary (Ubuntu 22.04 or any distro with an old restic)
VER=0.18.1 # check https://github.com/restic/restic/releases for the latest version
cd /tmp
curl -fLO https://github.com/restic/restic/releases/download/v${VER}/restic_${VER}_linux_amd64.bz2
curl -fLO https://github.com/restic/restic/releases/download/v${VER}/SHA256SUMS
sha256sum --ignore-missing -c SHA256SUMS # must print: OK
bunzip2 restic_${VER}_linux_amd64.bz2
install -m 0755 restic_${VER}_linux_amd64 /usr/local/bin/restic
restic version
Step 2: Choose an Off-Site Repository
The repository is where encrypted snapshots are stored. Pick one option and save it as /etc/restic/env (plain NAME=value lines).
Option A: S3-compatible object storage
Any provider with an S3 API works. Create a bucket and an access key limited to that bucket, and use the provider's endpoint in the URL:
# /etc/restic/env (S3-compatible object storage)
RESTIC_REPOSITORY=s3:https://s3.example.com/example-backups/vps1
RESTIC_PASSWORD_FILE=/etc/restic/password
RESTIC_CACHE_DIR=/var/cache/restic
AWS_ACCESS_KEY_ID=REPLACE_WITH_ACCESS_KEY
AWS_SECRET_ACCESS_KEY=REPLACE_WITH_SECRET_KEY
# Optional: monitoring ping URL (Step 11)
HC_PING_URL=
Option B: SFTP to a second server
Use another server you control, ideally in a different location or with a different provider, with a dedicated backup user that owns /srv/restic:
# /etc/restic/env (SFTP to a second server)
RESTIC_REPOSITORY=sftp:[email protected]:/srv/restic/vps1
RESTIC_PASSWORD_FILE=/etc/restic/password
RESTIC_CACHE_DIR=/var/cache/restic
HC_PING_URL=
# One-time SSH key login for root on the VPS
ssh-keygen -t ed25519 -N "" -f /root/.ssh/id_ed25519 # skip if the key exists
ssh-copy-id [email protected]
ssh [email protected] true # accept the host key once
Option C: an rclone backend
rclone connects restic to dozens of services (Backblaze B2, Google Drive, OneDrive, WebDAV and more):
# Install and configure rclone (interactive wizard, run as root)
apt install -y rclone # Rocky/Alma: dnf install -y rclone (EPEL)
rclone config # create a remote, for example named "offsite"
# /etc/restic/env (any rclone backend)
RESTIC_REPOSITORY=rclone:offsite:example-backups/vps1
RESTIC_PASSWORD_FILE=/etc/restic/password
RESTIC_CACHE_DIR=/var/cache/restic
RCLONE_CONFIG=/root/.config/rclone/rclone.conf
HC_PING_URL=
/etc/restic/env can usually delete backups too, so an attacker who takes over the VPS could wipe the repository. For stronger protection, enable bucket versioning or object lock, or use an append-only target, and keep the storage account password off the server.Step 3: Create the Password File and the Repository
install -d -m 0700 /etc/restic
openssl rand -base64 32 > /etc/restic/password
chmod 600 /etc/restic/password
nano /etc/restic/env # paste one of the examples above
chmod 600 /etc/restic/env
# Load the settings into the current shell and create the repository
set -a; . /etc/restic/env; set +a
restic init
/etc/restic/password and /etc/restic/env to a password manager or another safe place off the server now: they are all you need to restore if the VPS is gone.Step 4: Decide What to Include and Exclude
Back up configuration and data, not the whole operating system: reinstalling the OS is faster than restoring system files, and snapshots stay small.
# /etc/restic/include - one path per line; delete lines that do not exist
/etc
/root
/home
/srv
/var/www
/opt
/usr/local
/var/spool/cron
/var/backups/db
# /etc/restic/excludes
/root/.cache
/home/*/.cache
**/node_modules
**/*.tmp
/var/www/*/cache
- Include:
/etc, home directories, web roots, application data, crontabs and the database dump folder. - Skip:
/proc,/sys,/dev,/run,/tmp, swap files and caches. They are not on the list, so they are never read. - Raw database files (
/var/lib/mysql,/var/lib/postgresql) copied while the database runs can be inconsistent. Back up dumps instead (Step 5). - Docker: do not back up
/var/lib/dockeras a whole; back up your compose files and the volumes or folders that hold data.
Step 5: Dump Databases Before Each Backup
This script writes compressed dumps to /var/backups/db, which is on the include list. Set MYSQL and POSTGRES to match your server:
#!/bin/bash
# /usr/local/sbin/db-dump.sh - consistent database dumps before each backup
set -euo pipefail
umask 077
MYSQL=yes # yes / no
POSTGRES=no # yes / no
OUT=/var/backups/db
mkdir -p "$OUT"
if [ "$MYSQL" = yes ]; then
DUMP=$(command -v mariadb-dump || command -v mysqldump)
"$DUMP" --all-databases --single-transaction --quick \
--routines --events --triggers | gzip > "$OUT/mysql-all.sql.gz.tmp"
mv "$OUT/mysql-all.sql.gz.tmp" "$OUT/mysql-all.sql.gz"
fi
if [ "$POSTGRES" = yes ]; then
cd /tmp
runuser -u postgres -- pg_dumpall | gzip > "$OUT/postgres-all.sql.gz.tmp"
mv "$OUT/postgres-all.sql.gz.tmp" "$OUT/postgres-all.sql.gz"
fi
--single-transaction gives a consistent InnoDB dump without locking your site. On Debian and Ubuntu, root reaches MariaDB/MySQL through the local socket without a password; if yours needs one, put it in /root/.my.cnf (mode 600), not on the command line.
Step 6: Create the Backup Script
One script runs the dumps, the backup, retention and a weekly check, and reports to an optional monitoring URL (Step 10). Any failure stops it with an error, so problems are never silent:
#!/bin/bash
# /usr/local/sbin/restic-backup.sh - dump databases, back up, apply retention
set -euo pipefail
export PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
set -a; . /etc/restic/env; set +a
fail() {
[ -n "${HC_PING_URL:-}" ] && curl -fsS -m 10 --retry 3 "$HC_PING_URL/fail" >/dev/null || true
}
trap fail ERR
/usr/local/sbin/db-dump.sh
restic backup \
--files-from /etc/restic/include \
--exclude-file /etc/restic/excludes \
--exclude-caches \
--tag daily
restic forget --prune \
--keep-daily 7 --keep-weekly 4 --keep-monthly 6
# Sundays: verify the repository and read back 5% of the data
if [ "$(date +%u)" = 7 ]; then
restic check --read-data-subset=5%
fi
if [ -n "${HC_PING_URL:-}" ]; then
curl -fsS -m 10 --retry 3 "$HC_PING_URL" >/dev/null
fi
Make the scripts executable and run the first backup by hand. The first run uploads everything; later runs only send changes.
chmod 700 /usr/local/sbin/db-dump.sh /usr/local/sbin/restic-backup.sh
/usr/local/sbin/restic-backup.sh # first run by hand, watch for errors
restic snapshots
Step 7: Schedule It with a systemd Timer (or cron)
A systemd timer is the preferred option: it logs to the journal, catches up after a missed run (Persistent=true), and lowers the job's CPU and disk priority so your site stays fast.
# /etc/systemd/system/restic-backup.service
[Unit]
Description=restic backup to off-site repository
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/restic-backup.sh
Nice=10
IOSchedulingClass=idle
# /etc/systemd/system/restic-backup.timer
[Unit]
Description=Daily restic backup
[Timer]
OnCalendar=*-*-* 03:30:00
RandomizedDelaySec=30min
Persistent=true
[Install]
WantedBy=timers.target
systemctl daemon-reload
systemctl enable --now restic-backup.timer
systemctl list-timers restic-backup.timer
# Test the service once and read its log
systemctl start restic-backup.service
journalctl -u restic-backup.service -n 50 --no-pager
If you prefer cron, use this instead of the timer, not both:
# /etc/cron.d/restic-backup (only if you do not use the systemd timer)
30 3 * * * root /usr/local/sbin/restic-backup.sh >> /var/log/restic-backup.log 2>&1
Step 8: Retention with restic forget --prune
restic forget decides which snapshots to keep, and --prune removes data no snapshot needs any more. The policy in the script keeps:
| Option | What it keeps |
|---|---|
--keep-daily 7 | the last snapshot of each of the last 7 days |
--keep-weekly 4 | one snapshot per week for 4 weeks |
--keep-monthly 6 | one snapshot per month for 6 months |
Preview a new policy with --dry-run before relying on it:
set -a; . /etc/restic/env; set +a
restic snapshots
# Preview what a policy would remove, without deleting anything
restic forget --dry-run --keep-daily 7 --keep-weekly 4 --keep-monthly 6
restic stats --mode raw-data
The script also runs restic check every Sunday. It verifies the repository structure, and --read-data-subset=5% downloads a different 5% of the data each time, so corruption is found before you need the backup. Run restic check by hand whenever you want extra certainty.
Step 9: Test a Restore
A backup you have never restored is only a hope. Once a month, restore something real to a scratch folder and compare it:
set -a; . /etc/restic/env; set +a
restic snapshots
restic ls latest /etc/nginx
# Restore one directory to a scratch location, then compare
restic restore latest --target /tmp/restore-test --include /etc/nginx
diff -r /etc/nginx /tmp/restore-test/etc/nginx && echo "restore OK"
# Check that the database dump is complete (last line: "-- Dump completed on ...")
restic dump latest /var/backups/db/mysql-all.sql.gz | zcat | tail -n 1
rm -rf /tmp/restore-test
Practise the bad day once too, for example on a spare VPS: a new server with only the two saved files.
# On the NEW server: install restic, then restore the two saved files
install -d -m 0700 /etc/restic
nano /etc/restic/password && nano /etc/restic/env
set -a; . /etc/restic/env; set +a
restic restore latest --target /srv/restore
rsync -a /srv/restore/var/www/ /var/www/ # copy back what you need
zcat /srv/restore/var/backups/db/mysql-all.sql.gz | mariadb # or: mysql
restic restore latest --target / on a running system: it overwrites live files, including system configuration that may not fit the new server. Restore to a separate folder and copy back what you need.Step 10: Get Alerted When a Backup Fails
Backups usually fail quietly: an expired key, a full bucket, a changed password. A "dead man's switch" service fixes that: the script pings a URL after each successful run, and you get an alert when a ping is missing. Self-host one (for example the open-source Healthchecks) or use any similar service, and add your check URL to /etc/restic/env:
HC_PING_URL=https://hc.example.com/ping/REPLACE-WITH-YOUR-CHECK-ID
The script calls the URL on success and $HC_PING_URL/fail on error. Set the check period to one day with a few hours of grace. Without such a service, at least check restic snapshots and journalctl -u restic-backup.service every week.
FAQ
How much storage do I need?
Roughly the size of your data for the first snapshot (often less, thanks to compression), plus a little per day for changes. Run restic stats --mode raw-data after a few weeks to see the real figure.
restic says the repository is locked. What should I do?
An earlier run was interrupted and left a stale lock. Check that no restic process is running (pgrep -a restic), then run restic unlock and start the backup again.
restic uses too much memory on a 512 MB plan. Can I reduce it?
Add GOGC=20 to /etc/restic/env so restic frees memory more often, keep the include list focused, and make sure the VPS has swap. If it still runs out of memory, upgrade the plan from the service page. For help, open a ticket: JUSTG offers 24/7 AI support by ticket, and engineers follow up when a person is needed.