Parallel, per-user restic backups of large multi-user filesystems to AWS S3 (or, occasionally, Wasabi) buckets.
Each top-level directory under the source (typically one per user) is backed up into its own restic repository inside the bucket, so users can be backed up, expired, pruned, and restored independently and in parallel.
SOURCE BUCKET (AWS S3 / Wasabi)
/home/alice -> s3://orcd-backup-home/home3/alice (restic repo)
/home/bob -> s3://orcd-backup-home/home3/bob (restic repo)
/home/carol -> s3://orcd-backup-home/home3/carol (restic repo)
The orcd-restic-console repo
(production branch xfer) is the read/restore side of this system: a Flask web UI for
browsing snapshots and restoring files from the per-user repositories this script creates.
The two share the same conventions — restic version, password file (/root/.backup_pass),
the s3://<bucket>/<root>/<user> repo layout, and /stage for fast metadata cache.
This repo writes and maintains the repositories; the console reads and restores them.
Operator setup, day-to-day commands, manual restic backups, and ad hoc
restores of a whole directory or a single file are in MANUAL.md.
restic_backup_s3.sh uses restic's native S3 backend, talking directly to
the object store. It is vendor-agnostic via -c|--cloud:
-c aws(default) — credentials from the standard AWS chain (AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEYorAWS_PROFILE); endpoint derived fromAWS_REGION.-c wasabi— keys/endpoint from the[wasabi]section ofrclone.conf. Wasabi is used only sporadically and is not the primary target.
Everything restic-side (backup, retention, prune, locks, retry, cache) is identical on both providers; only credential/endpoint resolution differs.
A companion script, restic_repair.sh, fixes repos that fail prune with
missing-pack / "index is not complete" errors — see
Repairing damaged repositories.
restic's local backend writes every pack file under a temporary name
(data/xx/<hash>-tmp-<n>) and then renames it. S3 has no rename, so an rclone
mount emulates it as server-side COPY + DELETE. The result: every uploaded
pack also produces one deleted object of the same size (~17 MB each in
practice). On Wasabi, objects deleted before the minimum storage period are
billed as timed deleted storage — in our case this silently accumulated tens
of TB of "deleted storage" charges. The native S3 backend uploads each object
once under its final key: no rename, no COPY, no DELETE, and none of the VFS
dir-cache lock flakiness of the mount.
- bash >= 4.3,
flock, GNUfind - restic >= 0.17 (
--retry-lock,--skip-if-unchanged); tested with 0.19.REPACK_SMALLER_THANrequires restic >= 0.18; the exit-code-aware retry logic relies on restic >= 0.19 reportingbackupexit 3 / SIGINT exit 130 distinctly. - rclone — used only to verify the in-bucket root dir exists before a run.
-c aws (default) — nothing is read from rclone.conf. Credentials come from
the standard AWS chain restic understands natively: AWS_ACCESS_KEY_ID +
AWS_SECRET_ACCESS_KEY, or AWS_PROFILE + ~/.aws/credentials. The endpoint is
the regional AWS one derived from AWS_REGION (default us-east-1) — set
AWS_REGION to the bucket's actual region (or override S3_ENDPOINT), or the
run fails the root-dir check against the wrong regional endpoint.
-c wasabi (sporadic use) — keys and endpoint are read from
/root/.config/rclone/rclone.conf (single source of truth with the mount); no
separate AWS config is needed:
[wasabi]
type = s3
provider = Wasabi
access_key_id = ...
secret_access_key = ...
endpoint = s3.us-east-1.wasabisys.comrestic_backup_s3.sh [options]
-z <level> Compression level (auto|off|fastest|better|max, default: auto)
-c|--cloud <provider> Cloud provider: aws|wasabi (default: aws)
-s|--source <dir> Source directory containing per-user dirs
-b|--bucket <bucket[/pfx]> Destination bucket (default: orcd-backup-home)
-R|--root-dir <dir> REQUIRED root dir inside the bucket (e.g. home3);
must already exist unless ALLOW_NEW_ROOT=1
-u|--user <name> Run for this user only (repeatable: -u alice -u bob)
-n|--no-flock Bypass the global lock (requires -u); takes per-user locks
-r|--run Execute the backup
-f|--forget Apply retention policy (snapshots only, no data reclaim)
-p|--prune Reclaim unreferenced data (expensive; run monthly)
-r, -f, -p may be combined; order is always backup → forget → prune.
# Nightly: backup + retention for all users (AWS S3, the default;
# creds via AWS_PROFILE or AWS_ACCESS_KEY_ID/SECRET, AWS_REGION = bucket region)
AWS_PROFILE=backup AWS_REGION=us-east-1 \
restic_backup_s3.sh -s /home -b orcd-backup-home -R home3 -r -f
# Against Wasabi instead (keys read from rclone.conf [wasabi])
restic_backup_s3.sh -c wasabi -s /home -b orcd-backup-home -R home3 -r -f
# Monthly: reclaim unreferenced data (on Wasabi, run no more often than the storage minimum)
AWS_PROFILE=backup AWS_REGION=us-east-1 \
restic_backup_s3.sh -s /home -b orcd-backup-home -R home3 -p
# Ad hoc, one user only
restic_backup_s3.sh -s /home -b orcd-backup-home -R home3 -u alice -r -f
# Ad hoc while the scheduled full run is in progress
restic_backup_s3.sh -s /home -b orcd-backup-home -R home3 -u alice -n -r
# First-ever run against a new root dir
ALLOW_NEW_ROOT=1 restic_backup_s3.sh -s /home -b orcd-backup-home -R home3 -r# AWS credentials must be present in the cron environment (default provider is aws):
AWS_PROFILE=backup
AWS_REGION=us-east-1
# nightly backup + forget at 04:00
0 4 * * * /root/bin/restic_backup_s3.sh -s /home -b orcd-backup-home -R home3 -r -f >> /var/log/restic_backup.log 2>&1
# monthly prune, 1st of the month at 12:00
0 12 1 * * /root/bin/restic_backup_s3.sh -s /home -b orcd-backup-home -R home3 -p >> /var/log/restic_prune.log 2>&1| Variable | Default | Purpose |
|---|---|---|
CLOUD |
aws |
Provider (same as -c): aws or wasabi |
MAX_JOBS |
101 |
Parallel per-user jobs |
PACK_SIZE |
64 |
--pack-size (MiB); larger packs = fewer objects |
KEEP_DAILY / KEEP_WEEKLY / KEEP_MONTHLY / KEEP_WITHIN |
7 / 1 / 0 / 7d |
Retention policy (see below) |
RETRY_LOCK |
5m |
restic --retry-lock: wait for competing repo locks |
RETRIES / RETRY_DELAY |
2 / 15 |
Extra attempts per target / seconds between them |
MAX_UNUSED |
unlimited |
Prune --max-unused (unlimited = no unused-space repack, fastest) |
MAX_REPACK_SIZE |
unset | Prune --max-repack-size cap per run |
REPACK_SMALLER_THAN |
unset | Prune --repack-smaller-than (e.g. 1B to suppress small-pack repacking; see below) |
S3_CONNECTIONS |
5 |
restic -o s3.connections per process |
S3_ENDPOINT |
provider default | Endpoint URL (wasabi: from rclone.conf; aws: https://s3.<region>.amazonaws.com) |
RCLONE_CONF |
/root/.config/rclone/rclone.conf |
[wasabi] keys/endpoint source |
RCLONE_REMOTE |
wasabi |
rclone.conf section to read (wasabi mode) |
AWS_REGION |
us-east-1 |
aws mode: region for the endpoint (or AWS_DEFAULT_REGION) |
AWS_PROFILE |
unset | aws mode: profile in ~/.aws/credentials |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY |
unset | Explicit keys (override rclone.conf / AWS_PROFILE) |
RESTIC_CACHE_DIR |
derived | restic metadata cache (see below) |
PASS_FILE |
~/.backup_pass |
restic repository password file |
TMP |
/db/temp |
Lock files, failure log, per-user logs ($TMP/log/) |
ALLOW_NEW_ROOT |
0 |
1 = skip the root-dir-exists check (first run) |
The restic metadata cache is derived from the bucket name —
orcd-backup-<x> → /stage/backup/<x>/001/restic-cache:
| Bucket | Cache dir |
|---|---|
orcd-backup-home |
/stage/backup/home/001/restic-cache |
orcd-backup-software |
/stage/backup/software/001/restic-cache |
orcd-backup-pool |
/stage/backup/pool/001/restic-cache |
- Root-dir check — refuses to run if
-R <dir>contains no objects in the bucket, so a typo cannot silently start a brand-new tree and re-upload everything (ALLOW_NEW_ROOT=1overrides for genuine first runs). - Single-instance lock — one
flockper bucket+root prevents overlapping full runs.-n(with-u) downgrades it to per-user locks for ad hoc runs. - Repo detection before init —
restic initis only called when the repo is genuinely absent; transient errors are never mistaken for "missing repo". - Per-target failure tracking — failures are collected from the parallel jobs; the script reports a summary and exits non-zero if any target failed.
- Lock retry + unlock-between-retries — transient repo-lock collisions and
network/S3 errors are absorbed (
--retry-lockplus an unlock + retry). Non-transient failures fail fast instead of wasting retries:backupexit 3 (missing/unreadable source) and exit 130 (SIGINT) are not retried — but are still recorded in the failure summary. (forgetexit 3 is still retried, as it can be a transient lock/backend issue.)
Default policy (all env-overridable):
--keep-daily 7 --keep-weekly 1 --keep-monthly 0 --keep-within 7d.
These defaults are tuned for AWS S3 Standard, which has no minimum storage period, so shorter retention directly lowers stored GB-months.
On Wasabi, raise KEEP_WITHIN to at least the minimum storage period (30
days for reserved capacity, 90 for pay-as-you-go). Otherwise the monthly prune
deletes pack files younger than that minimum and they are billed as timed
deleted storage. A Wasabi-appropriate policy, for example:
KEEP_DAILY=30 KEEP_WEEKLY=4 KEEP_MONTHLY=3 KEEP_WITHIN=30d \
restic_backup_s3.sh -s /home -b orcd-backup-home -R home3 -fforget (cheap, metadata-only) and prune (expensive, deletes/repacks data)
are deliberately separate modes: forget nightly, prune monthly.
restic 0.19 repacks small pack files more aggressively by default. This
consolidation is independent of --max-unused, so even with
MAX_UNUSED=unlimited a prune downloads + re-uploads more small packs than on
0.17/0.18 — extra bandwidth and S3 request / Wasabi deleted-storage cost on every
prune. Set REPACK_SMALLER_THAN to pin it: a small value (e.g. 1B) suppresses
small-pack repacking (cheapest prune, but small packs accumulate over time); a
larger value (e.g. 16M) consolidates more (higher upfront cost, fewer objects).
Confirm the repack volume with restic prune --dry-run before settling on a value.
A prune may abort on a repo whose index references pack files missing from the bucket (typically fallout from the old rclone-mount era):
The index references 1 needed pack files which are missing from the repository: ...
Fatal: packs from index missing in repo
... or ...
Integrity check failed: Data seems to be missing.
Fatal: index is not complete
restic_backup_s3.sh -p reports each such repo as a prune failure (and writes the
detail to $TMP/log/<sfx>_<user>-prune.log). The companion restic_repair.sh
fixes them. It resolves credentials/endpoint/repo URLs exactly like the backup
script (same -c/-b/-R and AWS/Wasabi handling) and runs a check-gated
routine per repo, so a healthy repo is never modified:
unlock → check ─healthy→ (prune)
└─damaged→ repair index → check ─healthy→ (prune)
└─still damaged→ repair snapshots --forget → check → prune
repair snapshots --forgetis destructive: for repos still damaged after the index rebuild it rewrites the affected snapshots without the missing files and forgets the originals. That file content is gone from history; the next backup re-captures it from the live filesystem. Use--dry-runto preview first.
- Default — auto-discovers the failed repos by scanning the prune logs for the integrity signatures above (no manual list needed).
-u <name>(repeatable) or-L <file>— an explicit list.--save-list <file>— write the discovered names and exit without changes (capture now, repair later).
# Run right after a prune — repairs whatever just failed (auto-discovered)
AWS_PROFILE=orcd AWS_REGION=us-east-2 restic_repair.sh -c aws -b orcd-backup-home -R home3
# Preview only — no changes (shows `repair snapshots --dry-run`)
restic_repair.sh -c aws -b orcd-backup-home -R home3 --dry-run
# Capture the failed list now, repair from it later
restic_repair.sh -c aws -b orcd-backup-home -R home3 --save-list /root/repair.queue
restic_repair.sh -c aws -b orcd-backup-home -R home3 -L /root/repair.queueOther flags: --no-snapshot-repair (unlock + repair index only), --no-prune
(fix integrity, skip the final prune). MAX_JOBS defaults to 8 (repair is heavier
than backup). Per-repo logs go to $TMP/log/<sfx>_<user>-repair.log.
0 12 1 * * AWS_PROFILE=orcd AWS_REGION=us-east-2 /root/bin/restic_backup_s3.sh -c aws -b orcd-backup-home -R home3 -p >> /var/log/restic_prune.log 2>&1
0 20 1 * * AWS_PROFILE=orcd AWS_REGION=us-east-2 /root/bin/restic_repair.sh -c aws -b orcd-backup-home -R home3 >> /var/log/restic_repair.log 2>&1Keeping the rclone mounts for browsing/restores is fine — reads are harmless. Avoid restic write operations (backup/forget/prune) through the mount: any pack written via the mount goes through the tmp-rename path and generates deleted-storage charges. For pure queries, skip the repo lock entirely:
restic --no-lock -r /mnt/wasabi_backup_home/home3/alice snapshotsSet once in /etc/profile.d/restic.sh for convenience:
export RESTIC_PASSWORD_FILE=/root/.backup_pass
export RESTIC_CACHE_DIR=/stage/backup/restic-cacheThe script does not restore. Use restic directly (or the console). Snapshots
taken by this script are relative to the source directory: cd /home && restic backup ./alice stores files as /alice/... inside the snapshot, while the
snapshot's Paths column still shows /home/alice. Confirm with restic ls
before restoring. Full recipes are in MANUAL.md.
# list snapshots for a user (AWS)
AWS_PROFILE=backup AWS_REGION=us-east-1 \
restic -r s3:https://s3.us-east-1.amazonaws.com/orcd-backup-home/home3/alice \
--password-file /root/.backup_pass snapshots
# restore that user's tree into a scratch dir (name must start with "restore"
# if it lives directly under the source, or the next full run treats it as a user)
AWS_PROFILE=backup AWS_REGION=us-east-1 \
restic -r s3:https://s3.us-east-1.amazonaws.com/orcd-backup-home/home3/alice \
--password-file /root/.backup_pass \
restore 'latest:/alice' --target /home/restore-alicePer-user logs are written to $TMP/log/ (default /db/temp/log/) as
<bucket-component>-<root>_<user>-{backup,forget,prune,init}.log. The script
prints a failure summary and exits 1 if any target failed, 0 otherwise.