Scheduling restic check and Borg check without locking out production backups

Integrity checks fail in two ways. The obvious one is that they find corruption. The less obvious one is that they never run, or run so rarely that a repository can rot for a year before anyone notices. For a small fleet — one to ten machines, one operator, a VPS or two and a homelab box — the second failure mode is the common one, and it is almost always a scheduling problem dressed up as a discipline problem.

The instinct is to bolt restic check or borg check onto the end of the nightly backup script. That instinct is wrong, but not for the reason usually given. The contention that matters is not between a read-only check and a backup. It is between the maintenance jobs that take exclusive locks — prune, forget, compact — and the backup that is trying to write while they hold the repository. A check scheduled carelessly next to a prune is how you get a backup that fails at 02:15 and a check that reports clean at 02:20, and nobody looks at either until a restore is needed.

The lock is the schedule

Restic’s own documentation is blunt about this. In the section on removing snapshots: “During a prune operation, the repository is locked and backups cannot be completed. Please plan your pruning so that there’s time to complete it and it doesn’t interfere with regular backup runs.” The same page adds: “It is advisable to run restic check after pruning, to make sure you are alerted, should the internal data structures of the repository be damaged.”

That is the whole design constraint in two sentences. Prune is the disruptive job. Check is the job that should follow it, because prune is the operation most likely to expose a structural problem. If you schedule them as one maintenance window, you get the check for free at the moment it is most informative. If you schedule them as two independent cron entries an hour apart, you have created a race between a job that holds an exclusive lock and a job that needs to read the repository, and you have done so for no benefit.

Borg’s situation is different in detail and similar in shape. borg check is read-only by default: “The check command is a read-only task by default. If any corruption is found, Borg will report the issue and proceed with checking. To actually repair the issues found, pass --repair.” The retrieved Borg documentation does not state whether check takes a repository lock or blocks a concurrent create, so do not assume either way. What it does state is that check “always needs the key, --repository-only included: it loads the key (asking for the passphrase, if needed) before it checks anything and aborts if the key can not be loaded.” That matters for scheduling: a check that cannot read the key fails before it touches the repository, and a cron job that swallows that failure looks identical to a clean run.

Restic: separate the jobs, then spread the reads

The practical restic pattern for a small fleet is three jobs, not one:

  1. Backup, nightly, on the production host, with a credential that can write but not delete.
  2. Forget and prune, weekly, on a maintenance host that holds the delete-capable credential.
  3. Check, immediately after prune, on the same maintenance host, in the same window.

Keeping the delete-capable credential off the production host is the append-only pattern, and it is worth the extra host because it changes what a compromised production server can do to your history. It also means the maintenance host is the natural place to run the check: it already has the password, it already has the repository access, and it is not competing with a backup for I/O on the machine that is actually serving users.

The check itself does not have to read every pack every week. Restic’s check accepts --read-data-subset, which resticprofile’s reference documents as: “read a subset of data packs, specified as ‘n/t’ for specific part, or either ‘x%’ or ‘x.y%’ or a size in bytes with suffixes k/K, m/M, g/G, t/T for a random subset.” The n/t form is the one to use for a schedule, because it is deterministic: day 1 of 31, day 2 of 31, and so on. Over a 31-day month every pack is read once. Over a 30-day month, one group is skipped until the next long month — a real gap, but a bounded one, and far better than a full read-data check that never fits in the window and therefore never runs.

A percentage subset is simpler to write and worse to reason about. Restic’s own documentation notes that random subsets do not guarantee full coverage. If you use a percentage, you are choosing probabilistic coverage and you should say so in the runbook rather than pretending the schedule is exhaustive.

One more restic detail worth knowing before you panic at the output: restic check routinely reports pack files that are not referenced in any index. The FAQ is explicit that this is “not a source for concern” and is “with high probability this is duplicate data caused by an interrupted backup run or upload operation,” cleanable with restic prune. A check that exits clean but prints those lines is a pass, not an incident. Teach your alerting the difference, or you will page yourself at 3 a.m. for an interrupted upload from three weeks ago.

Borg: time-box the repository check, keep verify-data rare

Borg’s check splits into two phases that behave very differently under a schedule. The repository phase checks segment headers and the metadata and data of all objects, with size and CRC checks that catch bit rot. The archive phase checks archive metadata and, optionally with --verify-data, the actual file content.

The repository phase can be time-boxed. From the Borg 1.4 documentation: “The --max-duration option can be used to split a long-running repository check into multiple partial checks. After the given number of seconds the check is interrupted. The next partial check will continue where the previous one stopped, until the full repository has been checked.” The same page gives the arithmetic: a complete check that would take seven hours, run daily with --max-duration=3600, produces one full repository check per week.

There is a catch, and it is the one that bites people who skim. Partial repository checks cannot run archive checks. The 1.4 documentation states that with --max-duration “you must also pass --repository-only, and must not pass --archives-only, nor --repair.” The 2.x documentation repeats the constraint and adds the reason: partial checks “run neither archive checks nor repair mode.” So a time-boxed daily check verifies the repository’s bytes but says nothing about whether the archives’ metadata and chunk references are consistent. You need a separate, less frequent archive check to cover that half.

Borg 2.x adds --max-age, which lets a check reuse recent pack results: “packs whose intact result is younger than the given timespan (e.g. --max-age=4w or --max-age=12m) are skipped, spreading the verification cost over repeated checks.” This is not available in 1.x, so check which major version you are running before copying a schedule from a 2.x page. The 2.x documentation also notes that --max-age “affects only the repository check and cannot be combined with --archives-only or --repair.”

Then there is --verify-data. The 1.4 documentation calls it “a complete cryptographic verification and hence very time consuming, but will detect any accidental and malicious corruption.” The 2.x documentation is more emphatic: it “always verifies that each chunk’s content matches its chunk id, which normal reads do not do by default” and “running it periodically is therefore recommended.” Periodic does not mean nightly. For a small fleet, a monthly or quarterly --verify-data run, scheduled in a window long enough to finish, is the honest cadence. Putting it on a daily timer and hoping it completes is how you get a check that is killed halfway every night and never reports anything useful.

A schedule that fits one to ten machines

The following is a recommendation, not a finding. It assumes a nightly backup window, a maintenance host with delete rights, and a repository small enough that a full read-data pass is measured in hours rather than days. Adjust the cadences to your window; do not adjust the separation.

  • Nightly, production host: backup only. No check, no prune, no forget. The production host should not be able to delete its own history, and it should not be doing maintenance work while it is also serving.
  • Weekly, maintenance host: forget --prune, then restic check (structural, no --read-data) in the same window. This is the check the restic docs recommend after pruning.
  • Daily, maintenance host, staggered hours from the backup: restic check --read-data-subset=n/31, where n is the day of the month. Or, for Borg, borg check --repository-only --max-duration=3600 (1.x) or with --max-age=1w added (2.x).
  • Monthly, maintenance host: a full archive check. For Borg, borg check --archives-only without --max-duration. For restic, a full --read-data run if the repository is small enough, or accept the subset rotation as your coverage.
  • Quarterly, maintenance host: borg check --verify-data if you run Borg, in a window you have actually measured.

Stagger by hours, not minutes. If prune and check both need the repository and the prune takes longer than expected — and it will, the first time, because the first prune after a policy change repacks more than you think — a five-minute gap is a coin flip. A multi-hour gap is a schedule. Put the check after the prune in the same window, and put the daily partial check in a different window entirely, ideally at a time when no backup is running on any host that shares the repository.

Alert on “did not run” separately from “found errors”

This is the part that separates a schedule from a comfort ritual. A check that exits 0 because it found nothing and a check that exits 0 because it was skipped by a lock, or because the key was not readable, or because the wrapper swallowed a non-zero exit, look identical to a naive monitoring setup. They are not identical. One means the repository is intact. The other means you do not know.

Borg’s return codes are documented and worth wiring into your wrapper: 0 is success, 1 is a generic warning where “operation reached its normal end, but there were warnings — you should check the log,” 2 is a generic error, and with BORG_EXIT_CODES=modern the codes become more specific. A wrapper that treats anything other than 0 as a failure and anything other than 0 or 1 as a page is a reasonable starting point. Restic’s exit behavior is less granular, so the wrapper should also check that the check actually ran: a start timestamp, an end timestamp, and a duration that is not suspiciously short.

The failure mode to design against is the green check that never executed. If your monitoring only alerts on non-zero exits, a cron entry that silently stopped firing three months ago is invisible. Alert on the absence of a successful run, not just on the presence of a failed one.

What a clean check does not prove

A clean restic check or borg check proves the repository is internally consistent. It does not prove the repository contains what you need. A backup job that has been silently excluding the wrong directory for six months will produce a perfectly consistent repository with nothing useful in it, and every check will pass.

Pair the check schedule with a periodic restore test. Restore a file, restore a directory, restore a database dump, and time it. The check tells you the bytes are intact; the restore tells you the bytes are the right bytes. Neither substitutes for the other, and a schedule that only does the first is a schedule that will pass every test until the day it matters.

Before you trust the schedule

Three things to verify before you walk away from the cron file:

  1. Dry-run the policy. restic forget --dry-run and borg prune --dry-run print what would be removed without removing it. Run them once by hand, read the output, and confirm the retention rules match your intent. A policy that keeps nothing is a policy that will delete your history on the first real run.
  2. Confirm which host holds delete rights. If the production host can delete, the append-only split is not in place, and the check schedule is protecting a repository that a compromised server can still destroy.
  3. Restore something. Not a dry run, not a listing. An actual restore to a scratch directory, from the most recent snapshot, with the clock running. If it takes longer than your recovery objective allows, the check schedule is not the problem.

FAQ

Does restic check take an exclusive lock and block backups?

The retrieved restic documentation states that prune locks the repository and that backups cannot be completed during a prune. It does not state check‘s locking behavior. Do not assume either way; test it against your restic version, or simply schedule the check outside the backup window so the question does not matter.

Does borg check block a concurrent borg create?

The retrieved Borg check documentation does not state whether check takes a repository lock or blocks a concurrent create. It does state that check is read-only by default and that it always needs the key. Treat the lock question as open and schedule accordingly.

Can I use --max-duration with --archives-only?

No. Both the 1.4 and 2.x documentation state that --max-duration requires --repository-only and cannot be combined with --archives-only or --repair. A time-boxed check covers the repository phase only; archive checks need a separate, untimed run.

Is --max-age available in Borg 1.x?

It appears on the 2.x check documentation page and not on the 1.4 page. Check your version before using it in a schedule.

Why does restic check report packs not referenced in any index?

Restic’s FAQ describes this as uncritical, most likely duplicate data from an interrupted backup or upload, and cleanable with restic prune. It is not by itself a sign of corruption.

How often should I run --verify-data?

Borg’s 2.x documentation recommends running it periodically because normal reads do not verify chunk content against chunk IDs. The right cadence depends on your repository size and your window; for a small fleet, monthly or quarterly is a defensible starting point, provided the run actually completes. A daily --verify-data that gets killed halfway every night is worse than a quarterly run that finishes.

Scroll to top