Skip to content
Serverküche
Search

Loading search … (only available on the published site).

Security Difficulty: Intermediate

Restic backups: encrypted and off-site

Off-site backups with Restic: set up encrypted, restore snapshots, prune with retention and automate via a systemd timer.

· 11 min read ·Duration: approx. 45 minutes
Table of contents

A snapshot at the provider is not a backup: it sits on the same infrastructure and is gone too if your account is suspended, the data center burns down, or you accidentally delete the wrong project. Restic turns it into a real backup – encrypted, deduplicated and in a place outside your server.

What are we building?

By the end, Restic backs up your important data – app volumes, configurations, database dumps – end-to-end encrypted and deduplicated into a repository, ideally off-site (a second server or storage). You can restore individual files or whole snapshots at any time, automatically thin out old backups by a retention policy, and check integrity. Finally, everything runs automatically via a systemd timer and alerts you if a run fails to happen.

Tested with Restic 0.18 (the version from the Debian 13 package sources); upstream already has 0.19.x – how to update is in step 1. Restic always encrypts – there is no unencrypted repository.

Snapshot ≠ backup

The snapshot in the provider panel is a convenient “rewind” – but it sits on the same infrastructure and under the same account. If the provider, the data center or your access fails, the snapshot is gone too. A real backup lives outside (another provider/location), is encrypted and you can restore it independently. That’s exactly Restic’s job – the snapshot stays the quick safety net for small mishaps.

Prerequisites

  • A server (Debian 13) with the data you want to back up – e.g. the volumes and Compose files of your Docker apps.
  • An off-site target for the backups. Restic speaks, among others:
    • SFTP – a second server or a storage box (easiest to start with),
    • S3-compatible – e.g. an object-storage bucket,
    • rclone – as a bridge to Backblaze B2, Google Drive and dozens more.
  • Basic familiarity with the shell.
🍳 Recommendation Ad

VPS 1000 G12

4 vCores · 8 GB RAM · 256 GB NVMe

from €10.36/month

A small second VPS at a different location is a cheap, dedicated backup target via SFTP.

Go to netcup →

💶 5 € voucher for new netcup customers: 36nc17844976032 (new customers only, no domains)

Step by step

Step 1: Install Restic

Restic is in the package sources and is updated with the system:

Terminal
sudo apt update
sudo apt install -y restic

Check the version:

Terminal
restic version
Ausgabe
restic 0.18.0 compiled with go1.24.4 on linux/amd64

A newer version if needed

Upstream, Restic is sometimes ahead (currently 0.19.x). If you need a newer feature, download the official binary from the Restic release page and place it in /usr/local/bin (that takes precedence over /usr/bin). The built-in command restic self-update does not work with the Debian package version – the package is built without that feature (unknown command "self-update"). For most people the package version is entirely enough.

Step 2: Create the encrypted repository

A Restic repository is the encrypted storage location of your backups. It’s protected with a password – and this password does not belong in a script and not next to the repository. Put it in a file only root may read:

Terminal
sudo install -d -m 700 /etc/restic
openssl rand -base64 32 | sudo tee /etc/restic/password > /dev/null
sudo chmod 600 /etc/restic/password

The openssl command generates a long random password and stores it under /etc/restic/password (the install command before it creates the directory, the chmod after it protects the file). Tell Restic via environment variables where the repository is and where the password is – here first an off-site target via SFTP:

Terminal
export RESTIC_REPOSITORY="sftp:BACKUP_USER@YOUR_BACKUP_HOST:/backups/YOUR_SERVER"
export RESTIC_PASSWORD_FILE="/etc/restic/password"

Replace BACKUP_USER, YOUR_BACKUP_HOST and the path with your real target. If you want to practice locally first, use a folder instead: export RESTIC_REPOSITORY="/srv/restic-repo". The commands afterwards are identical – only the repository address differs.

You use other off-site targets via the same variable, only with a different prefix:

  • S3-compatible: s3:https://ENDPOINT/BUCKET – plus the credentials as AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY in the environment (or in the environment file from step 7).
  • rclone: rclone:REMOTE:path – the bridge to Backblaze B2, Google Drive and many more, once you’ve set up the rclone remote.

No matter which backend: encryption, deduplication and all the following commands stay the same.

Now initialize the repository (the example output shows the local practice repository – with the SFTP target, your sftp: address appears there instead):

Terminal
restic init
Ausgabe
created restic repository d29613209a at /srv/restic-repo

Please note that knowledge of your password is required to access
the repository. Losing your password means that your data is
irrecoverably lost.

Password lost = data lost

There is no back door. Without the repository password your backups are irrecoverably encrypted. Back up the contents of /etc/restic/password additionally in a password manager – separate from the server, otherwise it’s no use to you if the server is lost.

Step 3: The first backup

Back up the directory with your data. The --tag helps with filtering later:

Terminal
restic backup /root/backup-demo --tag demo
Ausgabe
no parent snapshot found, will read all files

Files:           3 new,     0 changed,     0 unmodified
Dirs:            2 new,     0 changed,     0 unmodified
Added to the repository: 2.002 MiB (2.001 MiB stored)

processed 3 files, 2.000 MiB in 0:00
snapshot f16c6ffd saved

On the second run, Restic’s strength shows: it only backs up the changes (deduplication). If a file changes and you back up again:

Ausgabe
using parent snapshot f16c6ffd
Files:           0 new,     1 changed,     2 unmodified
Added to the repository: 1.820 KiB (1.121 KiB stored)
processed 3 files, 2.000 MiB in 0:00
snapshot 0bb5e679 saved

Instead of 2 MiB again, only 1.1 KiB land in the repository – just the changed part.

What belongs in the backup? For Docker apps that’s three things – recipe, payload, DB:

  • Compose files (your “recipe”) and .env: small but indispensable for rebuilding the stack.
  • Bind mounts (./data:/…) live directly in the project folder – you back those up right along. Named volumes you’ll find under /var/lib/docker/volumes/<name>/_data; include the path.
  • Databases never as a live file – a backup mid-write is potentially unusable. Instead create a consistent dump (pg_dump/mariadb-dump) and back that up (see step 7).

You exclude the unnecessary:

Terminal
restic backup /opt --tag apps --exclude='*.log' --exclude='**/cache/**'

Step 4: Restore

A backup is only as good as its restore. First list the snapshots:

Terminal
restic snapshots
Ausgabe
ID        Time                 Host                  Tags        Paths              Size
---------------------------------------------------------------------------------------------
f16c6ffd  2026-07-16 20:52:35  v2200000000000000000  demo        /root/backup-demo  2.000 MiB
0bb5e679  2026-07-16 20:52:36  v2200000000000000000  demo        /root/backup-demo  2.000 MiB
---------------------------------------------------------------------------------------------
2 snapshots

Restore a complete snapshot to a target directory (use latest for the newest or a specific ID):

Terminal
restic restore latest --target /root/restore-test
Ausgabe
restoring snapshot 0bb5e679 of [/root/backup-demo] ... to /root/restore-test
Summary: Restored 5 files/dirs (2.000 MiB) in 0:00

If you only need one file, there are two ways – restore that specific path:

Terminal
restic restore latest --target /tmp/wiederher --include /root/backup-demo/config.yaml

… or stream the file directly to standard output, without any detour:

Terminal
restic dump latest /root/backup-demo/config.yaml > /tmp/config.yaml

To browse, you can even mount the repository as a filesystem (restic mount /mnt/backup, requires FUSE) and copy out what you need in the file manager – handy when you no longer know which snapshot the version you’re looking for is in.

Step 5: Check integrity

check verifies that the repository’s structure is intact:

Terminal
restic check
Ausgabe
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00%  2 / 2 snapshots
no errors were found

That checks the metadata, but not the actual data. So regularly read part of the real data along with it – e.g. 10% per run, i.e. everything over the course of weeks:

Terminal
restic check --read-data-subset=10%

Step 6: Prune with a retention policy

Without pruning, the repository grows forever. forget applies a retention policy, --prune frees the storage of the removed snapshots:

Terminal
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
Ausgabe
Applying Policy: keep 7 daily, 4 weekly, 6 monthly snapshots
keep 2 snapshots:
2 snapshots

This keeps the last 7 daily, 4 weekly and 6 monthly snapshots – a good default. The tiers interlock: fine resolution for the recent past (one per day), coarser the further back (one per week, then one per month). So, if in doubt, you can get close to the desired state without the repository growing unbounded. Older snapshots drop off automatically. --prune is the expensive part (it repacks data); on large repositories you often run it only once or twice a week, not on every backup.

Step 7: Automate everything (systemd timer)

A backup you have to start by hand is one you forget. Put the credentials in an environment file (so the service knows them):

Terminal
# /etc/restic/restic.env
RESTIC_REPOSITORY=sftp:BACKUP_USER@YOUR_BACKUP_HOST:/backups/YOUR_SERVER
RESTIC_PASSWORD_FILE=/etc/restic/password

Then a wrapper script /usr/local/bin/restic-backup.sh – it first dumps the database (PostgreSQL example), backs up, prunes and finally reports success to Uptime Kuma (the push monitor as a “dead man’s switch”):

Terminal
#!/usr/bin/env bash
set -euo pipefail
# 0) Ensure the target directory for the dump (otherwise the first run aborts)
mkdir -p /opt/dumps
# 1) Dump the database consistently (never back up the live DB file)
docker exec YOUR_DB_CONTAINER pg_dumpall -U postgres > /opt/dumps/db.sql
# 2) Backup
restic backup /opt/apps /opt/dumps --tag auto
# 3) Prune (retention)
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
# 4) Report success to Uptime Kuma
curl -fsS "https://status.YOUR_DOMAIN/api/push/YOUR_TOKEN?status=up&msg=restic+OK" > /dev/null
Terminal
sudo chmod 700 /usr/local/bin/restic-backup.sh

The service loads the environment file and runs the script – /etc/systemd/system/restic-backup.service:

INI
[Service]
Type=oneshot
EnvironmentFile=/etc/restic/restic.env
ExecStart=/usr/local/bin/restic-backup.sh

And the timer /etc/systemd/system/restic-backup.timer starts it nightly:

INI
[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true

[Install]
WantedBy=timers.target

Persistent=true catches up a missed run (e.g. because the server was off at night) on the next start. Enable it:

Terminal
sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer

Check with systemctl list-timers restic-backup.timer when the next run is due.

Don't let backup time collide with the automatic reboot

If the server runs an automatic reboot via unattended-upgrades (in that example 04:00), the backup must be done before then. If the running backup run (restic backup or the subsequent restic forget --prune) is cut off mid-operation by the reboot, a stale lock remains in the repository (repository is already locked, see below). That’s why the timer here starts at 02:30 – enough headroom for a larger backup plus pruning. If you adjust either time, deliberately keep the gap large.

Step 8: Rehearse the worst case once

A backup you’ve never restored is just a hope. So deliberately run through a complete restore once – ideally on a different or freshly set-up server, so you also need the credentials (repository address and password) under real conditions:

Terminal
export RESTIC_REPOSITORY="sftp:BACKUP_USER@YOUR_BACKUP_HOST:/backups/YOUR_SERVER"
export RESTIC_PASSWORD_FILE="/etc/restic/password"
restic snapshots
restic restore latest --target /tmp/dr-test

Then compare samples with the original (diff, file hashes) and boot the app from the restored data as a test. Only once that works do you know your backup holds up in an emergency – and that you’ll remember the procedure when it really counts.

When things go wrong

subprocess ssh: Host key verification failed with the SFTP target. Restic can’t confirm the SSH host key of the backup server because it isn’t known yet. Connect once manually (ssh BACKUP_USER@YOUR_BACKUP_HOST) and confirm the fingerprint – or store it with ssh-keyscan YOUR_BACKUP_HOST >> ~/.ssh/known_hosts. After that Restic runs through.

wrong password or repository does not exist. RESTIC_REPOSITORY or RESTIC_PASSWORD_FILE isn’t set or points nowhere – so check the EnvironmentFile in the systemd service. A manually set export only applies in the current shell.

repository is already locked. An aborted run left a lock. Check that really no backup is running anymore, then restic unlock. Never unlock blindly while a run is active in parallel.

The backup gets huge / backs up nonsense. Restrict it with --exclude/--exclude-file (caches, logs, temporary files) and use --one-file-system so Restic doesn’t dive into mounted foreign filesystems.

prune takes forever or was aborted. No reason to panic – prune is resumable and the repository stays valid. Start it again and afterwards run restic check once. Separate --prune from the daily backup on large repos (see step 6).

The timer runs, but a success message never arrives in Uptime Kuma. Look at the last run with journalctl -u restic-backup.service. Usually the script aborts before the curl call (e.g. the DB dump failed) – thanks to set -euo pipefail it then stops cleanly instead of reporting a broken backup as success. So the missing push isn’t a bug, but exactly the alarm signal you want.

Maintenance & backups

  • Test restores regularly – that’s the single most important point. A backup you’ve never restored is not a backup but a hope. Schedule a test restore into an empty directory monthly.
  • 3-2-1 rule: at least 3 copies, on 2 different media, of which 1 is off-site. Restic covers the off-site piece – but “off-site” really means a different provider/location, not the same data center.
  • Monitor backups: without monitoring, you only notice a dead backup in an emergency. The push call from step 7 to Uptime Kuma raises the alarm when a run fails to happen.
  • Check integrity: run restic check --read-data-subset regularly so silent bit rot is caught before you need the data.
  • Password/key management: keep the repository password separate from the server. For individual clients you can assign additional passwords with restic key add without sharing the main password.

Last updated: Jul 19, 2026

What's next?

You might also like