Reported symptoms
The quarterly restore rehearsal on rbdr-backup-01 fails at 09:40. The
snapshot restores six of its seven files and 59.401 MiB of the 60.000 MiB
it claims, and exits 1. The missing path is db/data.bin — the largest
file in the snapshot, and the only one anybody would have missed.
$ restic restore 3fe43af4 --target /srv/rbdr-restore-checkrestoring snapshot 3fe43af4 of [/work/prod] at 2026-08-28 13:27:02.65376235 +0000 UTC by root@8211a08b55c3 to /work/restore2
ignoring error for /work/prod/db/data.bin: decrypting blob <data/9a6d59cf> from pack 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f failed: ciphertext verification failed
Summary: Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB) in 0:00
Fatal: There were 1 errors
>>> exit code: 1Retrying fails identically. So does the older snapshot, and the newer one. The nightly integrity job has exited 0 every night for six months, including last night. The first hypothesis on the call is a stale index that can simply be rebuilt.
Evidence provided
$ stat /srv/rbdr-repo-prod/data/2c/2c3be6d1c7...951f Size: 17374653 Blocks: 33936 IO Block: 4096 regular file
Device: 254,1 Inode: 3934721 Links: 1
Access: (0644/-rw-r--r--) Uid: ( 998/ backup) Gid: ( 998/ backup)
Modify: 2026-02-19 03:41:52.118934771 +0000
Change: 2026-02-19 03:41:52.118934771 +0000
Birth: 2025-11-04 22:07:31.443019660 +0000Illustrative output
The course capture reproduces this fault class deliberately and records what it leaves behind on the filesystem.
$ overwrite 10 bytes at the midpoint of the largest pack and re-stat ittarget pack : /work/repo/data/2c/2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f
size : 17374653 bytes
10 bytes overwritten at the midpoint. Size is still 17374653
bytes, the mtime is the only filesystem-visible change, and no
monitoring that watches for missing or short files would fire.$ restic checkusing temporary cache in /tmp/restic-check-cache-2185980449
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
>>> exit code: 0$ restic check --read-data[0:00] 100.00% 2 / 2 snapshots
read all data
pack 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f contains 2 errors: [blob 9a6d59cfa25fce43e433aff4b16bb04d240c3730b027ae677bcb15719e44a436: decrypting blob <data/9a6d59cf> from pack 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f failed: ciphertext verification failed unexpected pack id 39e18fa9bf2f4acad3899cbe025e82aad203d9cfa1ee30017b1667a8d1e9bdc7]
[0:00] 100.00% 7 / 7 packs
The repository contains damaged pack files. These damaged files must be removed to repair the repository. This can be done using the following commands. Please read the troubleshooting guide at https://restic.readthedocs.io/en/stable/077_troubleshooting.html first.
restic repair packs 2c3be6d1c75844d268248b7a2a90e42f5d325095bd381b31c25bcc3d0179951f
restic repair snapshots --forget
Damaged pack files can be caused by backend problems, hardware problems or bugs in restic. Please open an issue at https://github.com/restic/restic/issues/new/choose for further troubleshooting!
Fatal: repository contains errors
>>> exit code: 1$ restic check --read-data (against the second copy)using temporary cache in /tmp/restic-check-cache-2505547008
create exclusive lock for repository
load indexes
check all packs
check snapshots, trees and blobs
[0:00] 100.00% 2 / 2 snapshots
read all data
[0:00] 100.00% 7 / 7 packs
no errors were found
>>> exit code: 0The nightly job is a cron entry running restic check with no further
arguments. It has never carried --read-data or --read-data-subset.
Work the evidence before reading on
- One repository produces exit 0 from one command and exit 1 from another, minutes apart. What does each command actually read?
- The proposal on the call is that the index is stale. Which single line of the evidence above makes that impossible?
unexpected pack id 39e18fa9…— where did that identifier come from, given the file is stored under a name beginning2c3be6d1c7?- The second copy passes. What does that tell you that the first copy’s failure alone does not?
- Six months of green. What was that dashboard measuring?
Root cause
Ten bytes were overwritten in place, and nothing below restic checked
A storage fault altered ten bytes inside one data pack. The file kept its name, its size of 17374653 bytes, its inode and its link count; only the modify time moved. Nothing beneath restic checksummed the block, so every filesystem-level assertion the estate could make — file present, not truncated, repository growing as expected — stayed true throughout.
Restic stores blobs under authenticated encryption, so altering the
ciphertext breaks the authentication tag and decryption is refused rather
than returning plausible garbage. That refusal is ciphertext verification failed, and it arrives only when something reads the pack.
The nightly control never read a pack
restic check without --read-data validates structure: the pack list,
the indexes describing it, and the trees and blob metadata reachable from
each snapshot. check all packs refers to that list and those index
entries, not to the bytes inside them. The nightly job was therefore
incapable, by construction, of detecting this fault class, and reported
that incapability as success 180 times.
Resolution
Preserve first. Copy the damaged repository before touching it. Do not run
prune or forget: both rewrite repository state, and both make the
damage permanent and unattributable.
Enumerate the loss from the restores rather than guessing. Each restore names every path it could not reconstruct.
export RESTIC_REPOSITORY=/srv/rbdr-repo-prod
SCRATCH=/srv/rbdr-restore-check
mkdir -p "$SCRATCH"
for SNAP in 3fe43af4 3e349a12; do
restic restore "$SNAP" --target "$SCRATCH/$SNAP" 2>&1 \
| tee -a "$SCRATCH/rbdr-blast-radius.txt" | grep 'ignoring error for'
done
Recover from the healthy copy. The capture shows what that copy is worth:
--read-data at exit 0 across all seven packs. Promote it, or repair the
damaged copy from it.
Verification
restic check --read-data on the recovered repository exits 0 and prints
read all data with the full pack count, not merely the snapshot count. A
structural pass alone is not accepted as the proof here; it is the control
that failed.
The restore exits 0, restores the full file and byte counts, and emits no
ignoring error for lines. Then the restored tree is compared against
reference digests taken from the source:
$ md5sum -c rbdr-reference.md5 ./app/app.conf: OK
./app/orders.csv: OK
./db/data.bin: OK
>>> md5sum -c exit code: 0Read the restore exit code and the checksum result together, never one without the other. Repeat for every retained snapshot: packs are shared between snapshots, so one passing restore proves only that one snapshot avoids the damaged pack.
Prevention
Schedule a pass that reads the data. restic documents
--read-data-subset, which reads a fraction of the repository per run, so
a repository too large to read nightly is still read completely over a
rotation. Keep the structural check as the fast daily signal; add the
reading pass as the slow one.
Alert on the age of the last successful data-reading pass, not on the exit code of the last structural check. The dashboard was green for six months because it measured the wrong number.
Watch for data files that change after they are written. A pack whose modify time is later than its birth time has been rewritten in place. Confirm nothing in your maintenance schedule does that, then treat any instance as a fault signal.
Put the repository on storage that checksums what it returns, so the layer beneath restic reports the fault at the moment of the read.
Keep a second copy on independent storage, and read that copy too. An unread copy is a copy of unknown state.
Rehearse restores with digest comparison. That is the control that eventually found this — the only one in the estate that read the data end to end for the reason it exists.