# Snapshot mode: user guide Snapshot mode turns an SSH push job into a versioned backup: every run writes a new dated copy on the receiver instead of updating one mirror. This guide explains how to set it up, what a run does, and what to do when it stops. All paths, hosts and ids below are examples (`/mnt/backup/…`, `backup@backup.example`, job id `j-appdata`). Use your own. Contents: - [What snapshot mode is](#what-snapshot-mode-is) - [Requirements](#requirements) - [Layout on the receiver](#layout-on-the-receiver) - [Setup, step by step](#setup-step-by-step) - [How a run works](#how-a-run-works) - [When a snapshot counts](#when-a-snapshot-counts) - [Options](#options) - [Retention](#retention) - [Safety nets](#safety-nets) - [Notifications and reasons](#notifications-and-reasons) - [Hooks](#hooks) - [Status and History](#status-and-history) - [Restoring](#restoring) - [Migrating an existing mirror](#migrating-an-existing-mirror) - [Backing up the flash drive (/boot)](#backing-up-the-flash-drive-boot) - [Security](#security) - [Turning snapshot mode off](#turning-snapshot-mode-off) - [Troubleshooting](#troubleshooting) ## What snapshot mode is A job normally keeps **one mirror** on the destination. In **snapshot mode** it keeps **dated, versioned copies** instead: every run writes a new directory on the receiver, and files that did not change since the previous snapshot are **hard links** to it (`rsync --link-dest`), so each snapshot costs only the space of what changed while still being a complete tree you can browse and restore from. A snapshot counts only once its run finished cleanly; old ones are deleted by a retention policy. | | Mirror (default) | Snapshot mode | | --- | --- | --- | | The destination holds | one copy, updated in place | one complete directory per run, named by its UTC start time | | History | none: yesterday's version of a file is gone once a run overwrites it | every kept snapshot is a full point-in-time tree | | A file deleted or encrypted on the source | is deleted or overwritten on the destination by the next run (with `--delete`, or when the file is rewritten) | older snapshots keep the old version; a run that looks wrong puts deletions on hold | | Space | the size of the source | the first snapshot is a full copy; each later one costs only the changed files | | Transports | SSH, rsync daemon, Local; Push or Pull | SSH and Push only | | Receiver | anything rsync can write to | GNU userland, root access, a target marker, a root you create | ## Requirements - **SSH transport, Push direction.** rsync daemon, Local and Pull jobs cannot use snapshot mode. This is checked when you save and again at every run, because the connection can change after the job was saved. - A receiver with **GNU coreutils and util-linux** (a normal Linux distribution; BusyBox, BSD and macOS receivers are not supported). Before every run the plugin self-tests what it needs on the receiver: `mkdir`, `mv -T`, symlink swap, `readlink -f`, `stat -c`, `df -Pk`, `sync -f`, `base64`, `sort`, `tail`, `nice`, `mktemp -d` and `rm --one-file-system`. `ionice` and `timedatectl` are used when present. - **Root on the receiver**: either the connection logs in as `root`, or its user has **passwordless sudo for `sh`** (which is full root) and the connection has **Use sudo on the receiver** on. The transfer itself must also read the snapshot root as root: with a non-root login, point the job's **Remote rsync path** at a small sudo wrapper (see [step 3](#setup-step-by-step)). Why: the root is mode 0700, and rsync must keep owners and permissions. - **rsync 3.2.4 or newer on both ends.** The plugin does not check the version. - A **target marker** with expected content (a UUID) on the backup drive. In snapshot mode it is mandatory, and the snapshot root must be the marker's directory or lie inside it. Why: it ties the root to the drive, so a run never writes into an empty mountpoint. - **Preserve times** (`-t`) on. Why: without modification times rsync never finds a file unchanged, so nothing is linked and every run is a full copy. ## Layout on the receiver ``` /mnt/backup/.unraid-rsync-target the target marker (holds a UUID) /mnt/backup/snapshots/appdata/ the job's snapshot root, 0700 root:root /mnt/backup/snapshots/appdata/.ur-snapshot-root sentinel: first line = the job id /mnt/backup/snapshots/appdata/2026-10-20T030000Z/appdata/... one snapshot, one dir per pair ("sub") /mnt/backup/snapshots/appdata/2026-10-21T030000Z/appdata/... /mnt/backup/snapshots/appdata/2026-10-22T030000Z.inprogress/ a run that did not commit (reused next run) /mnt/backup/snapshots/appdata/latest -> 2026-10-21T030000Z for humans; the plugin never reads it /mnt/backup/snapshots/appdata/.ur-hold deletions on hold (see below) /mnt/backup/snapshots/appdata//.ur-keep this snapshot is pinned, never pruned ``` Names are UTC stamps (`YYYY-MM-DDTHHMMSSZ`). Only a name that is exactly a stamp is a complete snapshot. Besides pruning old snapshots, the plugin only deletes its own leftovers: `.inprogress` copies of runs that did not commit and `.deleting` directories of an interrupted deletion. The newest unfinished copy is kept as a link basis only while its stamp is newer than the newest complete snapshot (or no snapshot is complete yet); every other one, including an outdated newest one, is deleted by the next real run. Nothing else in the root is ever deleted. Other names you may see: - `/.ur-snapshot.json`: the snapshot's metadata (file and byte counts per sub, free space before and after, whether it was suspect), mode 0600. The next run compares against it. - `.deleting/`: a snapshot being deleted. Deletion renames first and removes second, so an interrupted deletion never leaves a half-deleted directory under a valid snapshot name. - `.rolledback/`: left by the [rollback script](#migrating-an-existing-mirror). The plugin never touches it. ## Setup, step by step The paths below are examples; use your own. The job id is in the first line of every run log of the job (`Run started for job "Appdata" (j-appdata)`) and in `/boot/config/plugins/unraid.rsync/config.json`. 1. **On the receiver, as root, create the target marker** (skip if the job already has one) and note its content: ```sh cat /proc/sys/kernel/random/uuid > /mnt/backup/.unraid-rsync-target cat /mnt/backup/.unraid-rsync-target ``` 2. **Create the snapshot root (0700) and its sentinel** holding the job id. The plugin never creates either, so a missing drive can never be mistaken for an empty one: ```sh mkdir -p /mnt/backup/snapshots mkdir -m 0700 /mnt/backup/snapshots/appdata printf '%s\n' j-appdata > /mnt/backup/snapshots/appdata/.ur-snapshot-root ``` The root must be on the same filesystem as the marker, and not on the receiver's system disk. 3. **Non-root login only.** Give the user passwordless sudo for `sh` and for rsync through a wrapper, then set the job's **Remote rsync path** to `/usr/local/bin/rsync-sudo` and tick **Use sudo on the receiver** on the connection (Connections tab): ```sh printf '%s\n' 'backup ALL=(root) NOPASSWD: /bin/sh, /usr/bin/rsync' > /root/unraid-rsync.sudoers visudo -cf /root/unraid-rsync.sudoers && install -m 0440 /root/unraid-rsync.sudoers /etc/sudoers.d/unraid-rsync printf '%s\n' '#!/bin/sh' 'exec sudo -n /usr/bin/rsync "$@"' > /usr/local/bin/rsync-sudo chmod 0755 /usr/local/bin/rsync-sudo ``` Check the paths of `sh` and `rsync` on your receiver (`command -v sh rsync`). 4. **In the job** (Jobs tab): Transport **SSH**, Direction **Push**, the target marker path and its content, then turn **Snapshot mode** on and fill in the **snapshot root** and the retention. In each pair the right box becomes a **sub-directory name** (`appdata`, `docs`, ...). Tick **seed** on every pair for the first snapshot. Apply. The form fields and their defaults: | Field | Default | Allowed | | --- | --- | --- | | Snapshot root | empty (required) | absolute, normalised, at least two directories deep (three under `/mnt`), the marker's directory or inside it | | Keep snapshots: daily | 7 | 0–366 | | Keep snapshots: weekly | 4 | 0–260 | | Keep snapshots: monthly | 0 | 0–120 | | Max deletions per run | 3 | 1–20 | | Prune dry run | on | on / off | | Pair sub-directory name | empty (required) | 1–64 letters, digits, `_` or `-`, starting with a letter or digit; `latest` is reserved; unique within the job, ignoring case | | Pair seed | off | on / off | Daily + weekly + monthly must be at least 1. 5. **Dry-run, then Run.** A dry run checks everything on the receiver and lists what would be copied, and creates nothing. The first real run is a full copy; every later run links unchanged files to the previous snapshot. Once a pair is in a snapshot its **seed** flag has no effect (the log says so) and can be cleared. A pair added later needs **seed** for its first run. 6. **Pruning starts as a dry run.** With **Prune dry run** on (the default) every run logs its plan (`Prune plan: KEEP …` / `Prune plan: DELETE …`) and deletes no snapshot. Review those plans for a couple of weeks, then turn **Prune dry run** off and Apply. At most **Max deletions per run** snapshots are deleted per run; the rest wait for later runs. Prune dry run does not cover the cleanup of leftovers: unfinished `.inprogress` copies (all but a newest one that is newer than the last committed snapshot) and `.deleting` directories are still removed by every real run (unless `.ur-hold` exists). ## How a run works Every run of a snapshot job goes through these steps, in this order. Anything that fails before the pre-run hook fails the run without starting your hook, so a hook that stops containers never runs for a backup that cannot happen. ```mermaid flowchart TD A["Take the per-job lock, pick the stamp (UTC)"] --> B["Plugin checks: paths, options, target marker"] B --> C["Receiver checks: helper, root, clock, marker, filesystem, sentinel, free space, names"] C --> D["Host checks: transfer can read the root, sources not empty, link basis per pair"] D --> E["Cleanup of old leftovers (real runs, not on hold)"] E --> F["Pre-run hook"] F --> G["Create stamp.inprogress (real runs)"] G --> H["rsync per pair with --link-dest"] H --> I{"Completion gate: every pair exit 0 or 24, no abort?"} I -- no --> J["Keep stamp.inprogress, not committed"] I -- yes --> K["Re-check receiver, anomaly check"] K -- suspect --> L["Create .ur-hold, pin the previous snapshot"] K -- normal --> M["Commit: metadata, rename to stamp, swap latest"] L --> M J --> N["Post-run hook"] M --> N N --> O["Prune (real runs that committed and are not suspect)"] O --> P["Summary, History, notification"] ``` 1. **Lock and stamp.** The run takes the job's lock (a second run of the same job is refused with `already-running`), then takes its stamp from this server's clock in UTC. The log says `Snapshot mode: this run's snapshot is (UTC).` 2. **Plugin-side checks**, for every pair before anything is copied: the path guardrails, the snapshot settings (transport, root, marker, retention, sub names) and the [options](#options). A refused option fails the run with `snapshot-preflight`, any other rule with `guardrail`. Then the target marker is fetched over SSH and compared; a missing or wrong marker fails with `dest-not-mounted`. 3. **Receiver checks** through the plugin's helper script, run as root on the receiver over the job's SSH connection: - the helper file is verified first (a regular file, starts with `#!/bin/sh`, at most 32 KiB, sha256 as shipped); a modified helper fails the run; - `probe`: running as root (uid 0), the tool self-test, the receiver's clock. The log notes whether NTP is synchronised and whether `ionice` exists. A clock more than 120 s away from this server's fails with `snapshot-clock`; - `check`: the root is a real directory and canonical (no symlink in the path), the marker is a regular file whose first line is the expected content, the marker and the root are on the same filesystem and **not** on the filesystem of `/`, and the sentinel's first line is the job id; - `list`: the root's entries, `latest`, `.ur-hold`, the previous snapshot's subs and metadata, and free space. The receiver needs at least 5 % of the filesystem or 10 GiB free, whichever is larger; - the new stamp must sort after every stamp-shaped name in the root, or the run fails with `snapshot-clock`. 4. **Host checks.** The transfer's own SSH login (with the job's Remote rsync path) must be able to list the root. Every pair's source must exist, be a directory and not be empty. Every pair needs a **link basis**, `/`, or its **seed** flag. The log ends this part with `Snapshot root checked: previous snapshot `, plus `unfinished .inprogress reused as a link-dest` when the newest unfinished copy is newer than the newest complete snapshot (or none is complete yet). 5. **Cleanup** (real runs only, skipped while `.ur-hold` exists): leftover `.deleting` directories and every unfinished `.inprogress` copy are deleted, except the newest one when step 4 kept it as a link basis. An unfinished copy older than the last commit is deleted too. A failed deletion does not fail the run; it sets `prune-failed`. 6. **Pre-run hook**, if the job has one. 7. **`.inprogress` is created** (real runs only). Failure: `snapshot-mkstamp-failed`. 8. **rsync, one pair at a time**, into `/.inprogress//`, with `--info=stats2`, `--link-dest=//` when the pair has a basis, and `--link-dest=/.inprogress/` when an unfinished copy was kept. The `Quiet` log level runs as `Summary`, because the statistics feed the anomaly check. After each pair the log shows `Pair #1 (appdata): regular files, bytes.` 9. **Completion gate**: see [When a snapshot counts](#when-a-snapshot-counts). 10. **Commit**, when the gate passes: the receiver is checked again, the [anomaly check](#suspect-runs) runs, an Abort is honoured one last time, and then the helper writes `.ur-snapshot.json`, flushes it to disk, renames `.inprogress` to ``, points `latest` at it and flushes again. The log says `Committed snapshot . The filesystem grew by KB.` 11. **Post-run hook**, if the job has one. It always runs, also after a failure. 12. **Prune**: see [Retention](#retention). It runs after the post-run hook, so services the hooks stopped are already back. 13. **Summary, History and the notification.** **On a dry run** steps 1–4 run in full, so a dry run is a real test of the receiver. Nothing is created or deleted: no cleanup, no `.inprogress`, no commit, no prune. rsync runs with `-n` against the same link bases and lists what a real run would copy (a dry run's log level is never quieter than `Normal`). The hooks run too, with `UR_DRY_RUN=1`. When a previous snapshot exists, the log then shows `Dry run: the prune plan for the current listing (nothing is deleted):` and the plan. A dry run never counts as a run that did not commit. ## When a snapshot counts A snapshot is committed only when the run was not a dry run, was not aborted, every pair ran, and every pair's rsync exit code is 0 or 24. | Worst pair exit | Run state | Committed | Reason | | --- | --- | --- | --- | | 0 | SUCCESS | yes | none | | 24 (files vanished during the transfer) | WARNING | yes | none (a normal notification) | | 23 (some files could not be transferred) | PARTIAL | no | `snapshot-not-committed` | | 30, 35 (timeout) | TIMEOUT | no | `snapshot-not-committed` | | 20, 143 (Abort) | ABORTED | no | none | | anything else | FAILED | no | `snapshot-not-committed` | Exit 25 (`--max-delete` cap) cannot happen: deletion is forced off. **A run that does not commit** leaves `.inprogress` on the receiver and logs `Snapshot was not committed; .inprogress is kept for the next run.` It is never committed later. While the newest unfinished copy is newer than the newest complete snapshot (or no snapshot is complete yet), the next run uses it as a second `--link-dest`, so files it already received are linked instead of sent again. Every other unfinished copy is deleted by the next real run's cleanup, and so is the newest one once a later run has committed: an unfinished copy older than the last commit is never a link basis again. It is not a snapshot and is never counted by retention. **Abort** stops the run at the next safe point: between pairs, during a pair (rsync is stopped), during a deletion, or just before the commit. An aborted run never commits; its `.inprogress` is kept like any other. The post-run hook still runs. A runner that dies (a crash, a reboot) leaves its `.inprogress` too; the crash alert names it. ## Options Snapshot mode checks the job's **effective** options: the job's own, or Global Settings when the job uses the global config. A Global Settings change that breaks a snapshot job is refused when you save it. **Forced off for the run.** The stored setting is kept, a warning is shown when you save, and each run logs `