Skip to main content
RunBook Academy

Backup & DRVIII · Backup Repositories: restic, Borg and Repository FailureRepositories

The repository model: chunks, indexes and snapshots

Intermediate⏱ ~28 min🧪 Lab requiredresticborg

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

Not yet marked complete on this device.

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.

Service impact possiblethe first backup into an empty repository
$ restic backup /work/prod --tag daily
no 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: 0

Everything 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.

Service impact possiblethe chunk index after one archive into a fresh repository
$ 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: 0

20 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.

Read-only / Safetwo snapshots, each a complete view of the same 60 MiB tree
$ restic snapshots
ID        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 snapshots

Neither 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:

Data-loss riskprune asked what it would do before being allowed to do it
$ borg prune --list --dry-run --keep-daily=1 /work/repo
Keeping archive (rule: daily #1):            day2                                 Fri, 2026-08-28 13:58:07 [85d3e533e094eb96663fd26a148b14737667bfc4251eea6b7144827ea2d279b8]

>>> exit code: 0

The 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.

Data-loss riskan older snapshot, restored from a repository with one damaged pack
$ restic restore 3fe43af4 --target /work/restore2
restoring 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

Restored 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 index line 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, 41M of 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. forget versus prune is 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

  1. 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?

  2. 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?

  3. Q3. A snapshot that the listing reports as 60.000 MiB can be removed from a repository without a single stored byte being freed.

  4. Q4. Which of these follow from a snapshot being a set of references into a shared, content-addressed chunk store? Select all that apply.

  5. 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.