Encrypted off-site VPS backups with Restic
Build an automated Restic backup routine with retention, integrity checks, restore drills, and credentials that do not leak into shell history.

A backup is useful only when it is recent, off the server, and restorable. Restic covers the difficult parts: client-side encryption, deduplication, retention, and integrity checks. It supports SFTP and most object-storage providers.
This guide uses an SFTP repository, which means you need a second machine you control that this VPS can reach over SSH: another VPS in a different location, a storage box, or a server at the office. The same routine works with an S3-compatible target by changing the repository URL.
Decide what belongs in the backup
Back up application data, configuration, certificates, and database dumps. Do not blindly copy pseudo-filesystems or caches.
/etc
/home
/opt/apps
/srv
/var/lib/myapp
/var/backups/databases
Database files need special handling. Create a logical dump with pg_dump, mariadb-dump, or the application's own export command before Restic runs.
Install and store credentials safely
sudo apt update
sudo apt install -y restic curl
sudo install -d -m 700 /root/.config/restic
sudo touch /root/.config/restic/password
sudo chmod 600 /root/.config/restic/password
sudoedit /root/.config/restic/password
Put a long, random repository password in that file. Losing it means losing the backup, so store a second copy in your password manager.
Create /root/.config/restic/env:
RESTIC_REPOSITORY=sftp:backup@example.net:/vps-01
RESTIC_PASSWORD_FILE=/root/.config/restic/password
Protect it with chmod 600. The repository path is what the backup account will see after the chroot you set up in the next section: /vps-01 here lives at /data/vps-01 on the backup host's disk.
The systemd timer you will set up later runs Restic as root with no terminal attached. That means root needs passwordless SSH key authentication to the backup host: a password prompt in the middle of the night has nobody to answer it, and the backup silently never runs. Before generating anything, check whether /root/.ssh/id_ed25519 already exists. If it does, do not overwrite it: accepting ssh-keygen's overwrite prompt destroys the existing private key and with it root's access to every host that trusts it. Reuse the existing key instead, or generate a dedicated one under another name (for example -f /root/.ssh/id_ed25519_backup) and point SSH at it with a Host entry for the backup server in /root/.ssh/config.
sudo install -d -m 700 /root/.ssh
sudo ssh-keygen -t ed25519 -N '' -f /root/.ssh/id_ed25519
The key has no passphrase because nothing interactive will be around to enter one. The next section installs it on the backup host and puts a fence around what it can reach there.
Prepare the backup host
On the backup host, create a dedicated account and a directory to hold this server's repository:
sudo adduser --disabled-password --gecos '' backup
sudo install -d -m 755 -o root -g root /data
sudo install -d -m 700 -o backup -g backup /data/vps-01
Install root's public key. Print it on the VPS with sudo cat /root/.ssh/id_ed25519.pub, then on the backup host:
sudo install -d -m 700 -o backup -g backup /home/backup/.ssh
echo 'ssh-ed25519 AAAA...the full public key... root@vps-01' | sudo tee /home/backup/.ssh/authorized_keys
sudo chown backup:backup /home/backup/.ssh/authorized_keys
sudo chmod 600 /home/backup/.ssh/authorized_keys
A passphrase-less key needs that fence now. Confine the account to SFTP inside /data, so a stolen key cannot open a shell or read anything else on the machine. Create /etc/ssh/sshd_config.d/backup.conf:
Match User backup
ChrootDirectory /data
ForceCommand internal-sftp
AllowTcpForwarding no
X11Forwarding no
A syntax error in sshd configuration can lock you out of the backup host, so keep your current SSH session open, validate the config, and only then reload:
sudo sshd -t
sudo systemctl reload ssh
Back on the VPS, connect once interactively. This records the backup host's key in root's known_hosts, which the nightly timer relies on:
sudo sftp backup@example.net
Accept the host key when prompted, confirm that ls shows vps-01, and leave with bye. Then prove the connection works with no prompts at all:
printf 'ls\n' | sudo sftp -o BatchMode=yes backup@example.net
If that completes without asking for anything, the nightly job will authenticate too.
With SSH sorted, initialise the repository once. The credential files are readable only by root, so this and every later restic command must run from a root shell:
sudo -i
set -a
. /root/.config/restic/env
set +a
restic init
exit
Create the backup script
Save this as /usr/local/sbin/restic-backup. One line in it deserves respect before you run anything: restic forget --prune permanently deletes every snapshot that falls outside the retention policy, and there is no undo. If the repository already holds snapshots you care about, or you tweak the policy later, preview what would be deleted first by running restic forget --dry-run --keep-daily 7 --keep-weekly 5 --keep-monthly 12 from a root shell with the environment loaded.
#!/usr/bin/env bash
set -euo pipefail
set -a
. /root/.config/restic/env
set +a
install -d -m 700 /var/backups/databases
sudo -u postgres pg_dump --format=custom app_production \
> /var/backups/databases/app_production.dump
restic backup /etc /home /opt/apps /srv /var/backups/databases \
--exclude-caches \
--tag automatic
restic forget --keep-daily 7 --keep-weekly 5 --keep-monthly 12 --prune
curl -fsS --retry 3 https://hc-ping.com/your-check-uuid
Two lines need adapting to your server. Replace the pg_dump line with the dump command for your database, for example mariadb-dump --single-transaction app_production > /var/backups/databases/app_production.sql, or delete it if the server runs no database: with set -euo pipefail, a dump command that cannot run makes every backup fail. The final curl line pings a dead man's switch. Create a free check at healthchecks.io with a one-day expected period and paste in your check's URL; the service emails you whenever a ping fails to arrive, which catches every failure mode including the timer never firing. Swap in your own monitoring endpoint if you already have one.
Make it executable and run it manually:
sudo chmod 700 /usr/local/sbin/restic-backup
sudo /usr/local/sbin/restic-backup
Schedule it with systemd
Create /etc/systemd/system/restic-backup.service:
[Unit]
Description=Encrypted off-site backup
After=network-online.target
Wants=network-online.target
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/restic-backup
Create /etc/systemd/system/restic-backup.timer:
[Unit]
Description=Nightly Restic backup
[Timer]
OnCalendar=*-*-* 02:30:00
RandomizedDelaySec=20m
Persistent=true
[Install]
WantedBy=timers.target
Enable it with sudo systemctl enable --now restic-backup.timer. Persistent=true runs a missed job after a reboot.
Prove that restore works
List the snapshots from a root shell. The environment variables from earlier do not survive into a new session, so load them again first:
sudo -i
set -a
. /root/.config/restic/env
set +a
restic snapshots
Before restoring, check free disk space with df -h. A full restore writes a second copy of everything in the snapshot onto the local disk, which can fill the filesystem on a small VPS and take running services down with it. If space is tight, restore a subset by appending --include /etc to the restore command. Still in the same root shell:
install -d -m 700 /var/tmp/restore-test
restic restore latest --target /var/tmp/restore-test
Inspect files, perform a database restore into a disposable database, and exit the root shell when you are done.
Integrity checks should not depend on you remembering them, so schedule them the same way as the backup. Save this as /usr/local/sbin/restic-check and make it executable with sudo chmod 700 /usr/local/sbin/restic-check:
#!/usr/bin/env bash
set -euo pipefail
set -a
. /root/.config/restic/env
set +a
restic check --read-data-subset=5%
--read-data-subset=5% verifies the repository structure and downloads a random five percent of the stored data on each run, so successive months exercise the whole repository. Create /etc/systemd/system/restic-check.service:
[Unit]
Description=Monthly Restic repository check
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/restic-check
and /etc/systemd/system/restic-check.timer:
[Unit]
Description=Monthly Restic repository check
[Timer]
OnCalendar=monthly
RandomizedDelaySec=2h
Persistent=true
[Install]
WantedBy=timers.target
Enable it with sudo systemctl enable --now restic-check.timer.
The healthchecks ping in the backup script alerts you when a backup fails or stops running, the monthly check catches silent corruption, and a restore drill every quarter proves the pieces still fit together. That turns “we run backups” into “we can recover.”
Add exclusions and recovery notes
Keep caches and rebuildable artifacts out of the repository. Create /root/.config/restic/excludes:
/var/cache
/var/tmp
/opt/apps/*/node_modules
/opt/apps/*/.next/cache
Then edit /usr/local/sbin/restic-backup so the backup command picks it up:
restic backup /etc /home /opt/apps /srv /var/backups/databases \
--exclude-caches \
--exclude-file /root/.config/restic/excludes \
--tag automatic
Keep an explicit include list as well; an exclude-only backup can silently omit a newly added data directory.
The script's set -euo pipefail already aborts the run if the database dump or upload fails, and an aborted run never reaches the healthchecks ping, so the alert arrives without extra wiring. For object storage, use a separate bucket or prefix per server and grant the backup key no access to unrelated data.
Keep a recovery note outside the VPS containing the repository URL, password location, backup host access method, and exact restore command. A backup is part of incident documentation, not just a scheduled process.
Continue reading