Reported symptoms
At 09:20 the application team asks for one file back: db/data.bin, lost from
rbdr-fileserver overnight. The operator on shift runs the standard restore
from rbdr-repo, watches output scroll past, and reports that it “mostly
worked”.
Three files were expected. Two are present and correct. One is present and
wrong. restic exited 1.
The repository is neither new nor neglected. A nightly job has run
restic check against it since it was created and has exited 0 every night —
seven hundred consecutive runs. The backup health tile has been green for two
years and is still green while the call is happening.
The first hypothesis is the obvious one. The restore target is a different host from the source, its filesystem was resized four weeks ago, and a bad target would explain a partial write perfectly well.
Evidence provided
Every capture below is byte-for-byte from a controlled reproduction of this failure mode on restic 0.19.1. Its paths, IDs and hostname are its own.
$ restic versionrestic 0.19.1 compiled with go1.26.4 on linux/amd64$ 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 restore the snapshot into a fresh target directoryrestoring 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: 1$ md5sum -c against the checksums recorded before the backup ./app/app.conf: OK
./app/orders.csv: OK
./db/data.bin: FAILED
md5sum: WARNING: 1 computed checksum did NOT match
>>> verification exit code: 1$ overwrite 10 bytes at the midpoint of the largest pack, then 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.The restore was then repeated to a second target, on a different host and a different filesystem. The output was identical: same pack, same blob, same six of seven, same exit 1. No capture of the repeat is quoted here; none was taken.
Work the evidence before reading on
- Two commands ran against the same repository minutes apart. One exited 0 and one exited 1. What is different about what each of them read?
- The restore wrote 59.401 MiB of 60.000 MiB to the target before it failed. If the target filesystem were at fault, what would the error message have said instead, and on which file?
- The nightly job has been green for two years. Green about what, exactly?
- Before repairing anything, what do you need to obtain from somewhere else, and why does the order of operations matter?
Root cause
One data pack was altered in place, and authenticated encryption refuses to hand it back
restic stores file content as encrypted, authenticated blobs packed into pack
files. Ten bytes changed inside one pack are enough: the authentication tag no
longer matches the ciphertext, so restic refuses the blob rather than returning
plausible garbage. That is the ciphertext verification failed line, and the
unexpected pack id that follows is the same statement made about the pack as a
whole.
The refusal is why this incident is loud. A format that handed back the altered
bytes would have restored data.bin with exit 0 and given the application 60 MiB
of quietly wrong data, discovered weeks later or never.
The plausible alternative on the call was that the restore target was at fault. Three pieces of evidence eliminate it, and only the third is conclusive on its own:
- The error is a decryption failure raised while reading the repository, not a write error on the target. A full or failing target produces ENOSPC or EIO naming the destination path.
- The same target accepted 59.401 MiB of the same restore. A failing target would not write two files correctly and then fail on the third.
restic check --read-datanever touches the restore target at all, and it names the same pack2c3be6d1…and the same blob9a6d59cf…that the restore named. The defect is in the repository, observed from two directions. The identical error from a second target is confirmation, not proof.
The control that should have caught it reads the index, not the data
Plain restic check loads the indexes and walks the snapshots, trees and blob
metadata — the transcript says so in its own output: check snapshots, trees and blobs. It establishes that every blob referenced by every snapshot is accounted
for in some pack. It does not read what is inside those packs, which is why it
completes in seconds on a repository of any size.
This is not a defect in restic. It is what the command is documented to do, and
the speed is the point. The defect is that a cron entry running plain
restic check was reported on a dashboard as backup health, and every operator
who saw it green read that as “the backups are good”.
Resolution
Order matters more than speed, because the repair is destructive to the thing you are trying to recover.
Freeze retention on the affected repository. No forget, no prune, no
scheduled retention run until this is closed. Both rewrite repository contents.
Find a second copy and prove it before relying on it. A second copy is a hypothesis until every pack in it has been read:
RBDR_MIRROR=/srv/rbdr-repo-mirror
restic -r "$RBDR_MIRROR" check --read-data
echo "mirror read-data exit: $?"
$ restic check --read-data against the second copy of the repositoryusing 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: 0Recover the file from the verified copy, then compare it. Restore to a staging directory rather than over anything live, and let the checksum decide:
RBDR_MIRROR=/srv/rbdr-repo-mirror
RBDR_STAGE="$HOME/rbdr-restore-stage"
mkdir -p "$RBDR_STAGE"
restic -r "$RBDR_MIRROR" restore latest --target "$RBDR_STAGE"
echo "restore exit: $?"
(cd "$RBDR_STAGE" && md5sum -c /var/lib/rbdr-checksums/prod.md5)
Only now repair the damaged repository, with the commands --read-data
printed for that specific repository, having read the upstream troubleshooting
guide first. restic’s message is that the damaged files must be removed, which is
exactly why the recovery had to come first.
Re-run restic check --read-data on the repaired repository and require
exit 0 before it goes back into service.
Verification
restic check --read-data on the repaired repository reads every pack, prints
no errors were found, and exits 0. A plain check exiting 0 establishes nothing
here, and did not for two years.
The restore runs again end to end and reports the full count rather than a partial one:
$ restore the snapshot, then md5sum -c against the recorded checksumsrestoring snapshot 3fe43af4 of [/work/prod] at 2026-08-28 13:27:02.65376235 +0000 UTC by root@8211a08b55c3 to /work/restore
Summary: Restored 7 files/dirs (60.000 MiB) in 0:00
>>> exit code: 0
--- comparing the restored tree against the 09:00 checksums ---
./app/app.conf: OK
./app/orders.csv: OK
./db/data.bin: OK
>>> md5sum -c exit code: 0Both halves are required. A restore that exits 0 and a restore whose bytes match a checksum recorded independently of the backup system are two different claims, and only the second one is a recovery.
Prevention
Schedule a pass that reads the packs. The upstream restic documentation
describes --read-data-subset, which takes a fraction such as 1/16 so a large
repository is read whole across a known number of nights instead of never.
Test the restore, not the backup. A scheduled restore of a known file to scratch space, with the exit code captured and the bytes compared, would have found this on any of seven hundred nights.
Gate on the exit code and the counts. Restored 6 / 7 files/dirs reads as
progress. Wrap the restore, capture the status and the restored count, and treat
any shortfall as a failure.
Do not expect the filesystem to tell you. The damaged pack kept its size and changed only its mtime, so size, file-count and free-space monitoring all stayed quiet — correctly, because nothing they watch moved.
Keep a second copy, and verify it separately. The repair path existed only
because a second copy did, and was usable only because it passed --read-data.
Name the job after what it proves. A cron entry called rbdr-verify that runs
plain restic check will be read as “the backups are good” by everyone who sees
it green. Change the name, or change what it runs.