restic Backups on Debian 13: Hardened Setup

10 min read

A backup is only as good as your last successful restore. Most restic guides stop once restic backup works. Real failures happen later: a timer that quietly stopped firing, a repository password nobody wrote down, or ransomware on the client that deletes the backups too, because the client had permission to delete them.

This article sets up restic backups on Debian 13 from start to finish: the packaged binary, an SFTP or S3 repository, a sandboxed systemd service and timer, forget --prune retention, check --read-data-subset, a restore test that runs on a schedule, alerting on failures, and an append-only repository on rest-server.

What restic is for, and when to use it

restic is a single Go binary. It encrypts data on the client, splits files into content-defined chunks, deduplicates those chunks, and stores them in a repository. That repository can live on a local path, SFTP, a REST server, S3 and S3-compatible stores, Azure, GCS, B2 and more. The storage side never sees plaintext and needs no restic process. For SFTP or S3 that is convenient, but it also means nothing on the storage side can enforce policy. That matters for ransomware and is covered below.

Use restic when you want encrypted, deduplicated file-level backups to storage you don't fully trust, especially object storage. It does not replace a database dump. Dump the database to a file first, then back up the dump.

Installing restic on Debian 13

apt update
apt install restic
restic version

According to packages.debian.org, trixie ships 0.18.0-1+b4 (the Debian package tracker lists stable as 0.18.0-1; +b4 is a binary rebuild). Upstream has moved on since then: 0.18.1 came out on 2025-09-21, 0.19.0 on 2026-06-09 and 0.19.1 on 2026-07-05. Testing and unstable have 0.19.1-1, but the tracker marks it for autoremoval from testing on 2026-10-13 because of RC bugs in Go dependencies. The tracker lists no trixie-backports version.

For most setups 0.18.0 is fine. The repository format v2 (with compression) has been the default since 0.14.0. If you need a fix from a newer release, the restic docs point to the official release binaries on GitHub, which are reproducible builds with SHA-256 checksums and PGP signatures. Put one in /usr/local/bin and verify it before use. Only the official binaries support restic self-update.

Creating the repository: SFTP or S3

SFTP to a locked-down account

On the backup host, give each client its own chrooted, SFTP-only account. Use the same approach as in hardening SSH on Debian 13 with OpenSSH 10:

Match User restic-web01
    ChrootDirectory /srv/restic-chroot/%u
    ForceCommand internal-sftp
    AllowTcpForwarding no
    X11Forwarding no
    PermitTTY no

The chroot directory must be owned by root and must not be writable by the user. Create a repo directory inside it that the user owns. Warning: a broken sshd config can lock you out. Run sshd -t before systemctl reload ssh, and keep an existing session open.

On the client, create a password file and an environment file that systemd will reuse later:

install -d -m 700 /etc/restic
umask 077
head -c 32 /dev/urandom | base64 > /etc/restic/password
RESTIC_REPOSITORY=sftp:restic-web01@backup.example.net:/repo
RESTIC_PASSWORD_FILE=/etc/restic/password
RESTIC_CACHE_DIR=/var/cache/restic

For S3, use the path-style URL form from the docs, for example s3:s3.us-east-1.amazonaws.com/bucket_name or s3:https://minio.example.net/restic, and add AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY to the same file. Then initialise the repository:

set -a
. /etc/restic/env
set +a
restic init

restic prints the warning plainly: lose the password and the data is gone for good. Store a copy offline, for example in a password manager that does not live on the server you are backing up. restic key add creates a second key with its own password, and restic key list shows every key on the repository.

Real usage: backup, retention, check

restic backup --exclude-caches --tag manual /etc /srv
restic snapshots
no parent snapshot found, will read all files
Files:        2231 new,     0 changed,     0 unmodified
Dirs:          412 new,     0 changed,     0 unmodified
Added to the repository: 912.408 MiB (402.113 MiB stored)
processed 2231 files, 1.211 GiB in 0:41
snapshot 3f1a9c2e saved

(Trimmed; your numbers will differ.) Retention is set with forget. By default it groups snapshots by host and paths, and --prune removes the data that is no longer referenced. Warning: this deletes backup data. Always run it with --dry-run first:

restic forget --dry-run --keep-daily 7 --keep-weekly 5 --keep-monthly 12
restic forget --prune --keep-daily 7 --keep-weekly 5 --keep-monthly 12

A plain restic check verifies the structure but reads no pack data. --read-data downloads every pack. --read-data-subset spreads that work over several runs. It accepts n/t (group n of t, which gives full coverage after t runs), a percentage such as 5% (random packs), or a size such as 10G:

restic check --read-data-subset=5%
load indexes
check all packs
check snapshots, trees and blobs
no errors were found

A sandboxed systemd service and timer for restic

The backup service runs as root so it can read everything, and the sandbox takes away most of what root could otherwise do. The background is in sandboxing systemd services on Debian 13. Before each run, the service writes a canary timestamp that the restore test checks later. Make sure /var/lib/restic is listed in /etc/restic/includes (one path per line, next to /etc, /srv and so on).

[Unit]
Description=restic backup
Wants=network-online.target
After=network-online.target
OnFailure=restic-notify@%n.service

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
StateDirectory=restic
CacheDirectory=restic
ExecStartPre=/bin/sh -c 'date +%%s > /var/lib/restic/canary'
ExecStart=/usr/bin/restic backup --exclude-caches --files-from /etc/restic/includes --exclude-file /etc/restic/excludes --tag systemd
Nice=10
IOSchedulingClass=idle
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Unit]
Description=Nightly restic backup

[Timer]
OnCalendar=*-*-* 02:15:00
RandomizedDelaySec=30min
Persistent=true

[Install]
WantedBy=timers.target

Because ProtectHome=read-only stops ssh from writing to /root/.ssh/known_hosts, run one interactive restic snapshots first so the host key is accepted. Leave exit code 3 ("could not read some source data") counted as a failure. A partial snapshot is something you want to hear about.

For the SFTP or S3 setup, where the client is allowed to delete, a weekly maintenance unit handles retention and verification. It has two ExecStart= lines, which systemd only accepts with Type=oneshot, so here is the complete unit:

[Unit]
Description=restic retention and check
Wants=network-online.target
After=network-online.target
OnFailure=restic-notify@%n.service

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
CacheDirectory=restic
ExecStart=/usr/bin/restic forget --prune --keep-daily 7 --keep-weekly 5 --keep-monthly 12
ExecStart=/usr/bin/restic check --read-data-subset=5%%
Nice=10
IOSchedulingClass=idle
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Unit]
Description=Weekly restic retention and check

[Timer]
OnCalendar=Sun *-*-* 04:30:00
Persistent=true

[Install]
WantedBy=timers.target

Alerting on failures

[Unit]
Description=Report failure of %i

[Service]
Type=oneshot
ExecStart=/usr/local/sbin/restic-notify %i
#!/bin/bash
set -u
unit="$1"
host=$(hostname -f)
logger -t restic-notify -p user.err "$unit failed on $host"
if command -v mail >/dev/null 2>&1; then
  journalctl -u "$unit" -n 40 --no-pager | mail -s "[restic] $unit failed on $host" root
fi

OnFailure= fires only when the unit actually runs and fails. It says nothing if the timer never fires. The restore test below covers that case.

A scheduled restore test

The restore test restores only the canary from the latest snapshot, verifies the restored content, and fails if the canary is more than 26 hours old:

#!/bin/bash
set -euo pipefail
target=$(mktemp -d)
trap 'rm -rf -- "$target"' EXIT
restic restore latest --host "$(hostname)" --include /var/lib/restic/canary --target "$target" --verify
stamp=$(cat "$target/var/lib/restic/canary")
age=$(( $(date +%s) - stamp ))
if (( age > 93600 )); then
  echo "canary is ${age}s old, backups are stale" >&2
  exit 1
fi
echo "restore test ok, canary age ${age}s"

Wrap it in a service with the same environment, cache, sandbox and OnFailure= lines, and give it a daily timer at 07:00:

[Unit]
Description=restic restore test
Wants=network-online.target
After=network-online.target
OnFailure=restic-notify@%n.service

[Service]
Type=oneshot
EnvironmentFile=/etc/restic/env
CacheDirectory=restic
ExecStart=/usr/local/sbin/restic-restore-test
Nice=10
IOSchedulingClass=idle
ProtectSystem=strict
ProtectHome=read-only
PrivateTmp=yes
NoNewPrivileges=yes
CapabilityBoundingSet=CAP_DAC_READ_SEARCH
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
LockPersonality=yes
[Unit]
Description=Daily restic restore test

[Timer]
OnCalendar=*-*-* 07:00:00
Persistent=true

[Install]
WantedBy=timers.target

Then enable everything and audit the sandbox:

systemctl daemon-reload
systemctl enable --now restic-backup.timer restic-maintenance.timer restic-restore-test.timer
systemctl start restic-backup.service
systemd-analyze security restic-backup.service
systemctl list-timers 'restic*'

Once a quarter, also do a full manual restore of a real directory to a scratch disk. The canary proves the pipeline works. A full restore proves the data you actually care about comes back.

Append-only repositories against ransomware

With SFTP or plain S3 credentials, anyone who takes over the client can also delete the repository. The chroot limits what an attacker can reach, but it does not make the repository append-only. Debian 13 packages rest-server as restic-rest-server (0.13.0-2). Its --append-only mode lets clients create new backups but not delete or modify existing ones.

apt install restic-rest-server apache2-utils
install -d -o restic-rest-server -g restic-rest-server -m 750 /srv/backups
htpasswd -B /etc/restic-rest-server/users.htpasswd web01
LISTEN = :8000
BACKUP_DIR = /srv/backups/
ARGS = "\
  --htpasswd-file /etc/restic-rest-server/users.htpasswd \
  --append-only \
  --private-repos \
  --tls --tls-cert /etc/restic-rest-server/tls/fullchain.pem --tls-key /etc/restic-rest-server/tls/privkey.pem \
"

The packaged service runs as the unprivileged restic-rest-server user, so the certificate and key must be readable by it. Files copied from Let's Encrypt are usually root-only, and the service then fails to start:

chgrp restic-rest-server /etc/restic-rest-server/tls/fullchain.pem /etc/restic-rest-server/tls/privkey.pem
chmod 0640 /etc/restic-rest-server/tls/fullchain.pem /etc/restic-rest-server/tls/privkey.pem

The service will not start until BACKUP_DIR is set. The packaged unit is already sandboxed, so check systemctl cat restic-rest-server and the journal if writes fail. With --private-repos, the client URL becomes RESTIC_REPOSITORY=rest:https://web01:SECRET@backup.example.net:8000/web01/.

The client can no longer prune, so retention has to run on the backup host against the local path. rest-server uses the normal repository layout. Run the job as the restic-rest-server user (User=restic-rest-server in the unit). Otherwise new pack files end up owned by root and the server cannot read them. The trade-off is that the backup host now needs the repository password. The restic docs also warn that an attacker can inject fake snapshots into an append-only repository to push real ones out of a count-based policy, so prefer --keep-within there.

Options that matter

Option / variableWhat it doesNote
RESTIC_PASSWORD_FILEReads the repository password from a fileKeep it at mode 0600, with an offline copy
RESTIC_CACHE_DIRLocation of the local metadata cacheCan grow large; see no space left on device
--exclude-cachesSkips directories that contain CACHEDIR.TAGCheap size win
--one-file-systemDoes not cross filesystem boundariesTest with your mount layout
--compressionauto (default), off, fastest, better, maxNeeds repository v2
--skip-if-unchangedNo new snapshot if nothing changedKeeps the snapshot list short
--max-unusedUnused space prune may leave behindDefault 5%
--group-byGrouping used by forgetDefault host,paths; '' is risky

Pitfalls, limitations and alternatives

  • Lost password = lost data. There is no recovery. Add a second key and store it offline.
  • Path changes split retention groups. If you change the backup paths, the old snapshots become a separate group that your policy never thins out. Check restic snapshots after the change.
  • prune locks the repository. Backups cannot complete while it runs, so schedule the two apart. Only use restic unlock when you are sure no other restic process is running.
  • check costs bandwidth. --read-data downloads everything. On S3 that means egress charges.
  • Exit codes. 0.17.0 added 10 (no repository) and 11 (lock failed), and 0.17.1 added 12 (wrong password). The docs say any unknown code must be treated as a failure.
  • Debian lags upstream. Trixie stays on 0.18.0 and does not include the 0.18.1 or 0.19.x fixes.

borg: upstream 1.4.5 (2026-07-18). Trixie ships 1.4.0-5. Borg 2.0 is still beta (2.0.0b24, 2026-09-02, which upstream marks as not for production repositories). It has native append-only mode through borg serve, but it only works over SSH or local storage. kopia: v0.23.1 (2026-06-16) is a bugfix release that fixes a rare data-loss race, and upstream recommends upgrading. It has a GUI and a repository server. For S3 targets and mixed operating systems, restic is the simpler choice. For SSH-only setups where you want server-enforced append-only without running rest-server, look at borg.

restic cheat sheet

TaskCommand
Init repositoryrestic init
Back uprestic backup --files-from /etc/restic/includes
List snapshotsrestic snapshots
Preview retentionrestic forget --dry-run --keep-daily 7
Apply retentionrestic forget --prune --keep-daily 7 --keep-weekly 5
Partial data checkrestic check --read-data-subset=1/7
Restore a subtreerestic restore latest:/etc --target /tmp/r --verify
Stream one filerestic dump latest /srv/db.sql
Add a keyrestic key add

Takeaways

  • Password file at 0600, second key added, offline copy stored.
  • Sandboxed oneshot service, Persistent=true timer, OnFailure= alert.
  • Retention always previewed with --dry-run before forget --prune.
  • Rotating check --read-data-subset weekly.
  • Daily canary restore test plus a quarterly full restore.
  • Append-only rest-server if the client should not be able to delete backups.

Sources

Comments