Backup & DRVIII · Backup Repositories: restic, Borg and Repository FailureRepositories
The repository model: chunks, indexes and snapshots
What you'll learn
- Describe a repository as chunks, trees, snapshots and an index rather than as stored files
- Explain why content-defined boundaries survive an insertion that shifts every later offset
- Predict which repository operations cost stored bytes and which only move references
- Assess the blast radius of one damaged chunk across a set of recovery points
Prerequisites
Practice
Verified against restic 0.19.1 · BorgBackup 1.4.5 · rclone 1.75.0 · MinIO (S3-compatible object storage) RELEASE.2025-09-07T16-13-09Z · OpenZFS 2.4.1 · LVM2 2.03.31(2) · btrfs-progs 6.17.1 · PostgreSQL 18.6 · pgBackRest 2.59.1 · Kubernetes (k3s) and etcd k3s v1.36.3+k3s1, etcd 3.7.1 · Velero 1.18.2 · Docker Engine 29.7.2 · Proxmox Backup Server (documentation only) 4.0.10-1 · Ubuntu (host baseline) 26.04 LTS · 2026-08-28
A golden image is stored as a whole artefact: one object that means one machine, written in and read out as a unit, drifting away from the fleet at whatever rate the fleet changes. A repository-based backup tool works on the opposite principle, and the first thing to accept about it is that it never stores your files at all. It stores spans of bytes named by their own content, plus trees that describe how to reassemble them, plus a map from a name to a location. Nearly everything operators find counter-intuitive about restic, Borg and the chunk stores underneath backup appliances follows from that one design decision, so it is worth deriving the consequences rather than memorising them.
Four kinds of object, and only one of them holds your data
Open a repository directory and you will not find anything resembling the source tree. What is there falls into four categories, and keeping them distinct is the whole of the model.
A chunk is a span of bytes cut out of an input stream — part of a file, usually, and for small files the whole of one. Its name is a cryptographic hash of its own contents. That single choice is what makes the store content-addressed: the name is not assigned by the writer, it is computed from the data, so two identical spans of bytes arriving from different hosts on different days compute the same name and are therefore the same object. A chunk is written once and never modified, because modifying it would change its contents and therefore its name, which would make it a different chunk.
A tree is a small metadata object listing the entries of one directory: for each name, the type, the ownership and mode, the timestamps, and — for a regular file — the ordered list of chunk names that make up its contents. A tree referencing subdirectories references them by the hash of their tree objects, so a directory hierarchy becomes a hash tree in which every node’s identity depends on everything beneath it.
A snapshot — Borg calls it an archive — is the root of one such tree, plus the metadata an operator reads in a listing: when it was taken, from which host, which paths, which tags. It contains no file data whatsoever. It is a labelled pointer into a graph of objects that mostly already existed before it was created.
An index maps a chunk’s name to its physical location: which container file holds it, at what offset, with what length. It exists because content addressing gives you a name and no way to find the thing named. Chunks are not stored as individual files — a repository of any size holds millions of them, and one small file per chunk is the access pattern filesystems and object stores handle worst — so they are aggregated into larger containers, called packs in restic and segments in Borg, and the index is the only thing that can turn a hash back into an offset in one of them.
The index is derived data. It can be rebuilt by reading every container and re-listing what is inside, which is slow but always possible; the chunks and the trees cannot be rebuilt from anything. That distinction matters during a repair, and it is the reason repository damage in the metadata is usually survivable while damage in bulk data is not.
Boundaries chosen by content, so an insertion stays local
Deciding where one chunk ends and the next begins is not a detail. It is the decision that makes the whole model work or fail.
The obvious approach is to cut at fixed offsets: every 4 MiB, say. It is cheap, and it is correct for data whose offsets are stable, which is why block-level deduplication of volumes and virtual disks is a sound design. Applied to a file stream it collapses on the most common edit there is. Insert a hundred bytes near the beginning of a 4 GiB file and every boundary after the insertion moves by a hundred bytes; every chunk after the insertion is a different span of bytes with a different hash, and a system asked to store a hundred-byte change stores four gigabytes.
Content-defined chunking removes the dependence on position. A rolling hash is computed over a small sliding window as the stream passes, and a boundary is declared wherever that hash satisfies a fixed predicate. Because the predicate is evaluated on the bytes inside the window rather than on how far into the file the window has travelled, the cut points travel with the content. After the insertion, the window eventually contains exactly the bytes it contained before, the predicate fires in the same place, and every subsequent boundary lands exactly where it landed last time. Only the chunk containing the inserted bytes is new. Minimum and maximum sizes bound the outcome so that pathological input cannot produce one-byte or gigabyte chunks.
Then each chunk is hashed, and the hash becomes both its identity and its index key. Two properties fall out immediately. Identity is computed over the plaintext, before the chunk is encrypted and written into a container, which is what lets matching work at all inside an encrypted repository: two encryptions of the same bytes need not produce the same ciphertext, so a scheme that identified chunks by what is actually on disk would deduplicate nothing. Matching is nonetheless confined to one repository, because the index that resolves a name belongs to that repository; a chunk already stored in one store does nothing for a second one, which is why a second copy of a repository costs close to full size rather than close to nothing. And identity is independent of provenance: the tool has no concept of “this chunk belongs to that file”. A chunk is a leaf that any number of trees, in any number of snapshots, may point at.
Measured: the first run builds the store, the second one references it
A repository was initialised and a three-file, 60 MiB tree was backed up. Read this output as a description of the store being populated rather than as a report about files.
$ restic backup /work/prod --tag dailyno parent snapshot found, will read all files
Files: 3 new, 0 changed, 0 unmodified
Dirs: 4 new, 0 changed, 0 unmodified
Added to the repository: 60.005 MiB (60.008 MiB stored)
processed 3 files, 60.000 MiB in 0:00
snapshot 3fe43af4 saved
>>> exit code: 0Everything was new, so everything was written: 60.005 MiB added for 60.000 MiB of source. The figure exceeds the source because trees, the index and per-chunk framing are stored too — a repository is always slightly larger than the unique data in it.
One file was then changed and the same tree was backed up again. That run
reported Added to the repository: 2.062 KiB (1.370 KiB stored), and the point
is not the saving but the reason for it: the chunks of the two unchanged files
already existed under names the chunker recomputed identically, so the second
snapshot’s trees pointed at objects that were already there. Borg’s accounting
makes the same event legible by printing the index itself.
$ borg create --stats /work/repo::day1 .Archive fingerprint: fae87fac26949fa0ab2994a62819407fce5de3418d8d3bc23d4608fd466988c0
Time (start): Fri, 2026-08-28 13:58:06
Time (end): Fri, 2026-08-28 13:58:06
Duration: 0.12 seconds
Number of files: 2
Utilization of max. archive size: 0%
------------------------------------------------------------------------------
Original size Compressed size Deduplicated size
This archive: 41.94 MB 41.94 MB 41.94 MB
All archives: 41.94 MB 41.94 MB 41.95 MB
Unique chunks Total chunks
Chunk index: 20 20
------------------------------------------------------------------------------
>>> exit code: 020 unique chunks against 20 total is what an empty store looks like after one archive: every reference is the first reference. The second archive in the same capture moved that line to 23 unique chunks against 40 total — twenty new references resolved against three new chunks. The index grew by three entries; the archive list grew by one name.
Both snapshots nevertheless describe the entire tree, which the listing states plainly.
$ restic snapshotsID Time Host Tags Paths Size
-------------------------------------------------------------------------------
3fe43af4 2026-08-28 13:27:02 8211a08b55c3 daily /work/prod 60.000 MiB
3e349a12 2026-08-28 13:27:03 8211a08b55c3 daily /work/prod 60.000 MiB
-------------------------------------------------------------------------------
Timestamps shown in local time
2 snapshotsNeither row is a delta. There is no chain here, no full-plus-increments
sequence, and no parent that must survive for a child to be restorable: the
using parent snapshot line in a backup run is change detection, an
optimisation about which files get read, not a structural dependency. Every
snapshot is independently restorable as long as the chunks it names are present.
That is a different failure model from an incremental chain: better, because no
snapshot depends on another snapshot; worse, because they all depend on the same
shared chunks.
forget removes a name, prune removes bytes
Because a snapshot is a set of references, creating one is cheap and deleting one is cheaper. Deleting it does not touch a single chunk. It removes a root, and the objects that root pointed at are unaffected — most of them are still pointed at by other roots anyway.
This is why the tools separate the two operations. In restic, forget applies a
retention policy and removes snapshots; a dry run of it in this course’s capture
printed Would have removed the following snapshots: followed by {3fe43af4}
and exited 0. What a real run would have removed is the snapshot, and the
capture’s own note on that step says so: forget removes SNAPSHOTS. Space is reclaimed only by prune, and a chunk is deleted only when no remaining snapshot still references it. prune is the separate command that walks the live
snapshots, determines which chunks no longer have any reference, and rewrites
the containers holding them. Borg draws the same line between borg prune and
borg compact. Its prune is equally explicit about operating on archives:
$ borg prune --list --dry-run --keep-daily=1 /work/repoKeeping archive (rule: daily #1): day2 Fri, 2026-08-28 13:58:07 [85d3e533e094eb96663fd26a148b14737667bfc4251eea6b7144827ea2d279b8]
>>> exit code: 0The consequence operators trip over is that space appears on a different
schedule from deletion, and sometimes not at all. In the same capture, an
append-only Borg repository had three archives deleted successfully — the
deletes returned exit code 0 — and the repository still reported 41M of data
occupying /work/aorepo/data, because append-only forbids the compaction step
that would have reclaimed it. Removing every name from a repository is not the
same as emptying it.
The second consequence is that reclamation is non-linear. Deleting thirty snapshots can free almost nothing, because everything they referenced is still referenced by the thirty-first; deleting that one can free a great deal. Never plan capacity on the assumption that removing a month of recovery points recovers a month of storage. Establish the size on a dry run, then on a measurement after the reclaiming pass:
REPO=/work/repo
borg prune --list --dry-run --keep-daily=1 "$REPO"
borg check --verify-data "$REPO"
A chunk has many parents, so damage has many children
Every property so far has been favourable. Here is the bill.
In independent full copies, damage is contained: a bad byte in Tuesday’s copy is
Tuesday’s problem and Monday’s copy is untouched. A content-addressed repository
deliberately stores one instance of each chunk, so a chunk is shared by every
snapshot whose tree names it. Damage is shared on exactly the same graph. In the
capture, ten bytes were overwritten in the middle of the largest data pack, and
the restore attempted afterwards was of 3fe43af4 — the earlier of the two
snapshots, written before the second backup ran and long before the damage.
$ restic restore 3fe43af4 --target /work/restore2restoring 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: 1Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB), exit code 1. Age bought
that snapshot nothing, because the blob it needed was the single stored instance
that both snapshots referenced. The pack had already been named by
restic check --read-data, which reported that it contains 2 errors on a
repository the plain restic check had just passed with no errors were found.
That is the honest way to read a repository’s recovery position: not “how many
snapshots do we have” but “how many of them does this one object stand between
us and”.
Two habits follow. Verify in a way that reads the data, because structural checks cannot see inside a container. And when reporting recovery capability, report it per repository rather than per snapshot: the unit of independent failure is the store, not the recovery point.
What to take from this
- A repository holds chunks named by the hash of their own contents, trees that
reference them, snapshot roots and a derived index. The first backup of a
60.000 MiB tree reported
Added to the repository: 60.005 MiB (60.008 MiB stored), which is the store being populated, not files being copied. - The second backup of the same tree reported
Added to the repository: 2.062 KiB (1.370 KiB stored)on restic 0.19.1, because content-defined boundaries recomputed the unchanged files into chunks that were already present under the same names. - Both snapshots still listed at 60.000 MiB in
restic snapshots. A snapshot is a complete view, not a delta, and no snapshot depends on another snapshot for its restorability. - Borg 1.4.0 printed the index directly. Its
Chunk indexline reported 20 unique chunks against 20 total after the first archive, and 23 against 40 after the second: references grew twice as fast as stored objects. - Deletion and reclamation are separate. After three successful deletes from an
append-only borg repository,
41Mof data still occupied/work/aorepo/data, because the compaction step was forbidden. - One damaged pack reduced a restore of the older snapshot to
Restored 6 / 7 files/dirs (59.401 MiB / 60.000 MiB)with exit code 1. Shared chunks mean shared fate across every recovery point that names them.
Cross-course references
- Ceph & Distributed Storage for Production Sysadmins — Part XIII (CRUSH Fundamentals) shows the opposite answer to the question this lesson’s index solves. Ceph computes an object’s location from its name instead of storing a map, which removes the index as a scaling bottleneck and a rebuild task; reading the two designs against each other is the clearest way to see what a chunk index actually costs a backup repository.
- Linux for Production Sysadmins — Part III (Filesystems and Files) covers
inodes, hard links and the link count, which is the same reference-counted
deletion this lesson derives: unlinking a name does not free blocks until the
last reference is gone.
forgetversuspruneis that mechanism with the two steps separated into two commands an operator has to run. - Proxmox VE for Production Operators — Part XIII (Proxmox Backup Server) applies this exact chunk-store model to virtual machine images, where garbage collection over the chunk store is the operational surface that the reference counting described here explains, including why it is scheduled separately from removing backup groups.
Quiz
Knowledge check · 5 questions
Q1. A nightly `restic forget` has been applying a retention policy for six weeks. Snapshots disappear from the listing on schedule, backups still exit 0, and the repository has not shrunk by a single byte. What is the most likely explanation?
Q2. A 4 GiB append-structured file has 100 bytes inserted near its start and is backed up again into a chunk-addressed repository. The run stores roughly one chunk of new data rather than four gigabytes. Why?
Q3. A snapshot that the listing reports as 60.000 MiB can be removed from a repository without a single stored byte being freed.
Q4. Which of these follow from a snapshot being a set of references into a shared, content-addressed chunk store? Select all that apply.
Q5. A colleague plans to delete last quarter of snapshots on Friday afternoon to make room for a large migration on Monday. State what you would tell them about when the space appears.
Passing score: 75%. Answers are checked in this browser.