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.
Step by step
Step 1: Install Restic
Restic is in the package sources and is updated with the system:
sudo apt update
sudo apt install -y restic
Check the version:
restic version
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 commandrestic self-updatedoes 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:
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:
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 asAWS_ACCESS_KEY_IDandAWS_SECRET_ACCESS_KEYin 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):
restic init
created restic repository 9d28ae7601 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/passwordadditionally 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:
restic backup /root/backup-demo --tag demo
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 210e2933 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:
using parent snapshot 210e2933
Files: 0 new, 1 changed, 2 unmodified
Dirs: 0 new, 2 changed, 0 unmodified
Added to the repository: 1.829 KiB (1.116 KiB stored)
processed 3 files, 2.000 MiB in 0:00
snapshot 8d059af6 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:
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:
restic snapshots
ID Time Host Tags Paths Size
---------------------------------------------------------------------------------------------
210e2933 2026-08-20 01:35:39 v2200000000000000000 demo /root/backup-demo 2.000 MiB
8d059af6 2026-08-20 01:35:39 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):
restic restore latest --target /root/restore-test
restoring snapshot 8d059af6 of [/root/backup-demo] at 2026-08-20 01:35:39.79530411 +0200 CEST by root@v2200000000000000000 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:
restic restore latest --target /tmp/wiederher --include /root/backup-demo/config.yaml
… or stream the file directly to standard output, without any detour:
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:
restic check
using temporary cache in /tmp/restic-check-cache-175944302
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 2 / 2 snapshots
no errors were found
The first two lines are normal: check creates a throwaway cache and locks the repository so no second run interferes during the check.
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:
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:
restic forget --keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune
Applying Policy: keep 7 daily, 4 weekly, 6 monthly snapshots
keep 2 snapshots:
ID Time Host Tags Reasons Paths Size
----------------------------------------------------------------------------------------------------------------------
210e2933 2026-08-20 01:35:39 v2200000000000000000 demo oldest daily snapshot /root/backup-demo 2.000 MiB
oldest weekly snapshot
oldest monthly snapshot
8d059af6 2026-08-20 01:35:39 v2200000000000000000 demo daily snapshot /root/backup-demo 2.000 MiB
weekly snapshot
monthly snapshot
----------------------------------------------------------------------------------------------------------------------
2 snapshots
The Reasons column is the interesting part: it tells you for every kept snapshot which rule saved it. Nothing drops off here yet – both snapshots are from the same day and therefore "daily", "weekly" and "monthly" at once.
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)
How units and timers work in general is covered in systemd basics – here we apply the pattern directly.
A backup you have to start by hand is one you forget. Put the credentials in an environment file (so the service knows them):
# /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"):
#!/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
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:
[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:
[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:
sudo systemctl daemon-reload
sudo systemctl enable --now restic-backup.timer
Check when the next run is due:
systemctl list-timers restic-backup.timer
NEXT LEFT LAST PASSED UNIT ACTIVATES
Thu 2026-08-20 02:30:00 CEST 53min - - restic-backup.timer restic-backup.service
1 timers listed.
LAST/PASSED are empty before the first run. If you don't want to wait until night, start the service once by hand with sudo systemctl start restic-backup.service and look at the result with journalctl -u restic-backup.service.
⚠️ 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 backupor the subsequentrestic 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 at02: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:
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-subsetregularly 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 addwithout sharing the main password.
This post first appeared on serverkueche.de.
Top comments (0)