Changelog¶
All notable changes to the Vault plugin will be documented in this file.
The format is based on Keep a Changelog, and this project uses date-based versioning (YYYY.MM.PATCH).
[Unreleased]¶
[v2026.10.02] - 2026-10-17¶
Fixed¶
- Leaving the Logs page freezes the UI (console renders every log line) (#507)
[v2026.10.01] - 2026-10-16¶
Added¶
-
Memory statistics and a memory limit (#500): Settings → General has a new Memory card. It shows the daemon's heap in use, total Go memory, resident and peak RSS, the share of CPU spent in garbage collection, GC cycles, goroutines, and the effective memory limit. The same figures are in
GET /api/v1/settings/runtimeand the diagnostics bundle. The new Memory limit (MiB) setting (memory_limit_mb, default 0 = off) applies a soft limit immediately, with no restart. AGOMEMLIMITenvironment variable still takes precedence. The card lists S3 jobs whose upload buffers (part size × 5 × parallel uploads) could exceed the limit, the run log warns when such an upload starts, and the S3 form shows the per-stream buffer size under Part size. -
Real-time progress (#456): the dashboard and restore wizard show the current item's phase and the work actually done, such as "Uploading 84.2 GB / 225 GB — 37%", with the transfer rate, an estimated time left, and a "No progress for Xm" hint when an operation goes quiet. Remote uploads, verification read-back, and restore downloads report real byte counts across all of an item's files, instead of 0% for hours followed by a jump to 99%. Folder backups first scan the source ("Scanning… 12,340 files") and then report archiving and chunking against its size; incremental runs count unchanged files as done. Deduplicated restores report against the files being restored, VM backups report libvirt's byte counters, VM restores report disk copies, ZFS reports send and receive bytes, and container volumes show live bytes and files. A phase whose total is unknown shows its counters without a percentage.
GET /runner/status, thebackup_progress/restore_progressWebSocket events, and the MCPget_runner_statustool carry the new fields. - Troubleshooting detail in run logs (#455): backup and restore run logs now start with a "Run details" line (job, run, destination and storage type, restore point), time each stage of each item ("Phase upload completed for appdata in 4m12s"), and name the stage that failed. Retried storage requests, resumed streams, and restarted download parts each get a redacted warning line, capped at 20 per run. Item and summary lines report bytes stored, uploaded (including retries), read back, and restored, plus retry counts. Verification lines include bytes read and the time taken.
Changed¶
-
Lower memory churn on the data plane (#500): deduplicated backups no longer allocate an 8 MiB chunking buffer for every file. A backup of 40,000 small files on Unraid went from 4,877 garbage collections (about half of its CPU) to 16, and ran about 3× faster. Folder and container archives reuse pooled copy buffers instead of allocating 32 KiB per file. Deduplicated backups seal chunks straight into the pack buffer and no longer copy each pack (up to 24 MiB, more for large chunks) before uploading it. Chunk reads reuse a pooled buffer. VM disk, NVRAM and database-dump copies to local paths use the kernel's
copy_file_rangeagain, which allows block cloning on ZFS 2.2 and later. Progress reporting, cancellation and stall detection are unchanged. -
Test coverage (#458): backend statement coverage rises from about 79% to about 93%, and web library line coverage from 65% to 98%. The new tests cover the libvirt VM, ZFS, FUSE mount, container, runner, storage (SFTP, SMB, WebDAV), API, CLI and database paths. Narrow, unexported test seams were added where code talked to libvirt, ZFS, FUSE, SMB or the kernel directly; production behaviour is unchanged.
- Selective restore size (#455): restoring a few files from a backup item reports the bytes actually restored instead of the whole item's recorded size, in History, the run log, and the restore summary. Restore summaries also name the job and destination.
-
mDNS advertisement control (#468): new Network Discovery (mDNS) setting (
MDNS_ADVERTISEinvault.cfg) and--mdnsflag onvault daemonandvault replicaturn LAN discovery off without changing the bind address. Both default to on. -
Ansible deploys install the real
plugin/rc.vaultinstead of an outdated inline copy, so live verification runs the same service script the plugin package ships (#468). - Verification cost warning (#457): the job form now warns when Verify backups after writing is on and the destination is remote: under the toggle, beside the destination in the express form, and on the review step. After every backup, Vault reads all the data that backup references back from that destination, including chunks reused from earlier backups, so a large backup can mean significant bandwidth and a long wait near 100%. S3 and S3-compatible destinations also note possible request, retrieval, or egress charges. The warning suggests scheduled verification for large backups.
- Formatting hooks no longer fail on a clean checkout: files merged without pre-commit were reformatted (no content changes), workflow YAML keeps the organization's single-quote style through
.prettierrc.json, and a new CI Format job runs the same pinned formatting hooks on every pull request. - Pull requests get path labels and at most one type label,
bugorenhancement, from the shared tinkerologie PR labeler. Dependabot pull requests whose largest update is a patch or minor version merge automatically once the checks and commit statuses on their head commit have finished without a failure (a Codecov check that is still pending is not waited for; a Codecov failure blocks); majors, and pull requests with open review threads, stay for review. - Dependabot also updates the Playwright end-to-end test dependencies (root
package.json) and the Docker base images in the rootDockerfile. - Release workflows hardened (least-privilege job permissions, no expressions in shell scripts, no persisted checkout credentials, no dependency caches); scheduled workflows run on Australia/Brisbane time.
- Workflow layout tidied after the release hardening (no behaviour change):
permissions:blocks moved back to the top of their workflow or job indocs.yml; blank-line layout restored inci.yml,codeql.yml,docker.yml,release.yml; theid-token: writecomment inci.ymlnames the Codecov OIDC upload. - Releases push the updated PLG hash to
mainwith a token from the Tinkerologie-Release GitHub App, the only actor allowed to bypass the pull-request rule onmain, so the Unraid plugin manager and Community Applications see each new release. - Tinkerologie-Bot governance report updated to v1.4.3: an older run can no longer post a stale report after a newer run passed. Workflow formatting tidied.
- Repository standardised to the tinkerologie layout (STANDARDS.md in tinkerologie/.github): workflows renamed to the standard set (CI, Validate, Release, PR Governance, ...), every action pinned to a commit SHA, shared Dependabot,
.editorconfig,.gitattributesandCODEOWNERSfiles, and organization-wide labels and community files. - Tinkerologie-Bot's comment is now a readable "Pull request checklist": each problem is a numbered heading with plain-English guidance and its items listed underneath, and report updates for a pull request are serialised so an older run can never overwrite a newer one.
- Contribution governance: issues without a
bugorenhancementlabel are labeled automatically when their type can be determined (issues labeledquestion,duplicate,invalid,wontfix, orno-typeare skipped), so CodeRabbit can plan them. Pull requests that conflict with the default branch are labeled, and their authors are notified in a comment. Stale issues and pull requests are labeled after 14 days without activity and closed 30 days later. PR governance now re-validates a human-authored, non-draft PR when a bot such as CodeRabbit edits its description, instead of skipping the check and reporting a pass, only maintainers with write access may useNoneinstead of linking an issue, and a link to an issue that does not exist now fails the check. Codecov patch coverage must now reach 90%. - Tinkerologie-Bot now posts one comment on pull requests that fail PR governance, listing every problem to fix, and updates it once they are fixed. PR governance runs from the default branch's workflow (
pull_request_target), so a pull request cannot change its own check. - Tinkerologie-Bot governance report updated to v1.3.3: the report is capped below GitHub's comment size limit, and an older run that finishes late can no longer overwrite a newer report.
- Updated repository, plugin template, documentation and docs-site URLs to
tinkerologieorganization (Go module path unchanged; GitHub redirects keep it working) - Configured Codecov GitHub Actions workflow for tokenless coverage uploads
Fixed¶
- UI froze when leaving the Logs page (#507): the log console put every loaded line on the page, about 8,600 rows, or 78,000 DOM elements, on a server with a long history. Moving from Logs to any other section stalled for up to several seconds, especially in Chrome with an accessibility tool attached. The console now shows the newest 200 lines and adds more as you scroll down, keeping at most 600 on the page. Jump to oldest shows the oldest 200 lines, and scrolling up from there adds newer ones. Returning to the newest lines trims back to 200. Leaving Logs now takes about as long as leaving any other page (10–20 ms instead of 55–145 ms, or seconds).
- Flaky adaptive re-check test (#506):
TestAdaptivePostponeRecheckA458now waits for the re-checked run to finish finalizing before its temporary directory is removed. It had failed CI onmainwith "directory not empty" whenever the database backup was still writing. - WebDAV uploads failing with "423 Locked" during parallel uploads: gowebdav re-creates every parent directory before each upload. When two uploads into the same backup folder overlapped, a server that locks a directory while creating it (such as Go's
x/net/webdav) answered 423, and one upload failed even though the folder existed. An MKCOL answered 423 is now re-sent briefly (which also works for Digest-authenticated servers), and one that still returns 423 for a directory that exists is treated as "already exists", like the 403 case from #242. A 423 for a missing directory is still retried. - Unrealistic transfer rates for short operations (#455): a backup or restore that finishes in under a second no longer reports rates like "10000 GB/s". Run logs, Discord notifications, History and the Activity timeline only show a rate for operations of at least one second, and divide by precise elapsed time rather than whole seconds. Run logs and the web UI show sub-second durations as
<1sinstead of0sand keep a fractional second under a minute (2.4s); run log data includesduration_ms. - mDNS answers leaked to other interfaces (#498): with Vault bound to one LAN address, a host on a different local network (a second NIC, or a Docker or libvirt bridge) that asked for
_vault._tcpstill got that address back, even though Vault only announced it on the bound interface. Query answers now come only from the interfaces Vault advertises on: the bound interface for a specific address, or every interface with a routable address for a wildcard bind (0.0.0.0), the same set that announcements already used. Loopback binds still advertise nothing. Vault carries the fix as a small patch tobrutella/dnssdunderthird_party/dnssduntil upstream releases it. - Unraid host renamed after binding Vault to a LAN address (#468): a non-loopback bind no longer makes
avahi-daemonreport a host name conflict and rename the server (for exampleTheVault.localtoTheVault-2.local). Vault now advertises_vault._tcpunder its own host name,vault-<hostname>.local, answers address lookups for that name, and publishes on each interface only the addresses the API listens on there (the mDNS responder moved fromgrandcat/zeroconftobrutella/dnssd). The service name and TXT records are unchanged, so Home Assistant discovery keeps working. - Deployment and verification remove temporary SSH password files when the server is unreachable, while keeping connection errors visible.
- Ansible no longer prints the Unraid password (#495): the deploy and verify roles passed it on the command line (
sshpass -p), so a failed upload or SSH tunnel printed it in the task output, and it showed in the local process list while a deploy ran.sshpassnow reads it from a private temporary file that is deleted when the run ends, and error messages stay visible. - Logs page unresponsiveness and freeze when loading older entries (#454): Fixed microtask starvation caused by concurrent older-page requests during initial mount and background loading loops (
loadAlland search). Active older-load requests now share the in-flight Promise across callers, older requests are bound to the store context sequence to discard stale responses, and background loading loops terminate when no progress is made. Also added a storedispose()method, called from the synchronousonMountcleanup inLogs.svelte, to abort background activity on page navigation. Closes #454.
[v2026.10.00] - 2026-10-07¶
Added¶
-
Deduplicated backups can be recovered with the backup password (#451): Each dedup destination's master key was sealed only with the server's
vault.key, so losing that file made every dedup backup unreadable. Dedup backups now also keep a copy of the master key sealed with the backup password, written by the first dedup backup that runs with a password set. After a flash-drive loss or reinstall, the Recovery wizard uses the backup password to unlock dedup destinations and re-seal them with the new server's key; the previous_vault/repo.jsonis kept as a.bakcopy. Restore, browse, mounts andvault recoveralso open a destination with the password when the key does not match. Settings → Security → Server key downloadsvault.keyfor an off-server copy, and the emergency kit mentions it. Closes #451. -
Recover files without a Vault server, including on Windows (#313): The new
vault recovercommand reads backups straight from a storage destination and extracts files into a local folder. It needs no Vault database and runs on Linux, macOS and Windows. A Windows build,vault-windows-amd64.exe, is attached to each release.vault recover listshows the backups,contentslists an item's files, andextractrecovers whole items or chosen paths. Deduplicated backups, classic backups, incremental and differential chains, and age-encrypted backups are supported. Deduplicated backups need the original server'svault.key, or the backup passphrase if one was set when they ran (#451); encrypted classic backups need the backup passphrase. The passphrase is read from a file or theVAULT_PASSPHRASEenvironment variable. Containers are recovered as their volume files plus the Unraid template and configuration, and VMs as their disk images and definition. Storage is opened read-only and stored checksums are verified. On Windows, names Windows cannot store and names that differ only by case are renamed, and every rename is listed in a report written next to the recovered files. NFS exports must be mounted first. See the Disaster Recovery guide. Closes #313. -
Multi-job item restores in restore wizard (#438): The restore wizard now supports restoring selected items across different backup jobs in a single restore flow. In Step 2, restore points are aggregated across all relevant jobs with visible source job badges, item coverage counts, missing item explanations, and legacy membership indicators. Users can choose one restore point per source job, view a real-time coverage plan, and use one-click recovery actions to remove uncovered items. In Step 3, restore units are reviewed with scoped destination overrides, passphrases, clean destination controls, and per-unit pre-flight checks, followed by sequential execution and live multi-unit progress tracking. Closes #438.
-
Headless Playwright browser regression test suite: Added an end-to-end browser testing suite with GitHub Actions CI workflow (
playwright.yml) and deterministic REST/WebSocket mocks (tests/mocks/vault-api.ts), validating desktop shell navigation and multi-job restore wizard flows headless without requiring a live Unraid host. -
Expanded Playwright browser test coverage across core UI workflows: Added automated end-to-end browser specifications for Backup Jobs (creation wizard, item pickers, schedule/retention, run now, delete), Storage Management (destination creation, test connection, remote browser drawer, refresh, delete), Settings (tab navigation, dark/light theme, Discord webhook tests, diagnostics zip export), Activity Logs (search and level filters, wrap/details toggle, log purge), History (operation metrics, search, run log inspector), and Disaster Recovery (5-step cold recovery wizard).
Changed¶
- Uninstalling keeps
vault.key(#451): Removing the plugin no longer deletes the server key, so a reinstall opens the same deduplicated destinations. Delete/boot/config/plugins/vault/by hand to remove every trace of Vault.
Fixed¶
-
Clear message when a dedup destination needs a different
vault.key(#451): Browsing, restoring, mounting andvault dedupnow explain that the destination was sealed with another server's key and how to recover, instead of a generic error. Browsing returns HTTP 424 with codededup_key_mismatch. A backup that runs on a mis-keyed destination still completes, with a warning in its run log. -
Replication no longer bounces jobs between reciprocal peers (#440): When two Vault servers are configured as each other's replication source, a sync now imports only the jobs the remote server created itself (
source_id0) and skips jobs it had replicated from elsewhere. Previously each sync re-imported the other side's copies, creating ever-deeper[A] [B] …job names. Only the pulling server needs the update. Nested jobs created before this fix are not removed automatically; delete them manually, or delete and re-create the affected replication source. The Replication page now describes replication as pulling from the remote server. Closes #440. -
Partial folder restores keep directory owner and permissions (#442): Restoring selected files from a folder backup now applies each containing directory's recorded owner, group and mode, instead of creating it as
root:root 0755. Previously SMB users could not rename, move or delete files in the restored directory. Applies to deduplicated and classic backups. Unselected sibling files are still not restored. Closes #442. -
FUSE backup mounts moved out of
/mntroot (#446): Read-only backup mounts now live under Unraid's/mnt/addons/vault-fuse/mount-<id>, or under a temporary directory when/mnt/addonsdoes not exist, and each mount's directory is created only when that mount starts. If a mount directory cannot be created under/mnt/addons/vault-fuse, Vault retries once under the temporary directory (unlessfuse_mount_base_diris configured). This clears the Fix Common Problems warning "Invalid folder vault-fuse contained within /mnt". On startup Vault removes the old/mnt/vault-fusedirectory and leftover empty session directories, never deleting non-empty content. Uninstall removes both mount roots only when empty, and pool discovery never treatsaddonsorvault-fuseas storage pools./mnt/user/.vaultis Vault's database snapshot directory (<pool>/.vault, shown under/mnt/userbecause Unraid exposes top-level pool folders as shares); it is intentional and can be moved under Settings → General → Database Location. Closes #446. -
Partial-restore file browser no longer times out on slow remote storage (#449): Listing a large restore point on B2/S3 can take longer than the normal 10–15 second request limits, so the file browser failed with "vault daemon unavailable". Only the restore-point contents request now gets longer limits: 120 seconds in the daemon, 125 seconds in the Unraid plugin proxy and 130 seconds in the browser. All other requests keep their current limits. A listing that runs past 115 seconds now gets a clear timeout error from the daemon instead of a dropped connection. The proxy now reports a timed-out request as HTTP 504 "vault daemon request timed out" instead of "vault daemon unavailable", and stops waiting after 5 seconds when the daemon cannot be reached. When the file list fails to load because of a timeout, network error or server error, the picker now shows "Could not load the file list" with a Retry button, and no longer says a whole-archive extract will run. While a file list is loading or has failed to load, Start Restore is disabled until the list loads or you choose Restore whole item, so a timeout can no longer turn an intended partial restore into a full one. The whole-item fallback note is shown only when the restore point cannot be browsed. Closes #449.
Security¶
- The backup password now also protects deduplicated backups (#451): Anyone with the backup password and access to the backup storage can read deduplicated destinations, as with
vault.key. Use a strong backup password.
[v2026.09.01] - 2026-09-28¶
Added¶
-
FUSE read-only mounts for backups (#312): Operators can now mount deduplicated backup snapshots as standard read-only filesystems on the host to inspect archive contents, compare versions, or copy out specific files using standard Linux shell tools and file managers. Backups can be mounted directly from Step 3 of the Restore Wizard via "Mount as Filesystem" or through the
vault mountCLI subcommand. Active mounts are tracked and managed via SQLite and exposed through REST API routes (GET /api/v1/mounts,GET /api/v1/mounts/{id},POST /api/v1/mounts/{id}/unmount,POST /api/v1/jobs/{id}/restore-points/{rpid}/mount), WebSocket event broadcasting (mount.started,mount.active,mount.unmounted,mount.failed), and an "Active Backup Mounts" card on the Restore page with path copying and one-click unmounting. Inactive mounts are automatically reclaimed by an idle timeout sweeper. Closes #312. -
Direct file downloads from storage browser modal (#311): Operators can now download individual files directly from the Storage browser dialog. Files streamed from destinations include an appropriate
Content-Disposition: attachment; filename="..."header, and the browser UI offers per-file download actions with in-flight loading indicators and toast feedback. Closes #311. -
Restore progress, live run logs, and completion status on restore page (#316): Surfaced real-time restore progress and results directly on the Restore page. Step 3 of the Restore Wizard now features an overall progress bar with active item, item type, elapsed time, and phase indicators driven by WebSocket runner events. Once the restore finishes, an outcome banner details item success/failure counts and total restored bytes with a quick reset action, and an embedded live restore log panel displays formatted, level-coded log messages streamed from the daemon. Additionally, global toast notifications alert operators when a restore operation completes or fails. Closes #316.
-
Future jobs queue visibility and cancellation (#302): Concurrently triggered backup, restore, and cleanup operations are now tracked with unique queue identifiers, operation kinds (
backup,restore,delete), and statuses (queued,running). An "Active & Queued Operations" card is now surfaced at the top of the History tab with real-time WebSocket synchronization. Operators can cancel queued backup and restore operations directly from the queue before execution begins viaPOST /api/v1/queue/{id}/canceland the web UI. Closes #302. -
Auto-add newly created containers to container backup jobs (#324): Jobs configured to protect Docker containers can now enable "Auto-include newly created containers". During preflight reconciliation, newly deployed containers on the host are automatically discovered, configured with
appdata_only=true(safely excluding bulk media mounts by default), and added to the job's item list. Containers tagged with thevault.excludelabel are respected and ignored. Additionally, containers missing past the configurable absence grace period are automatically pruned from the job. Closes #324. -
Appdata path prompt and default exclusion of non-appdata volumes (#317): When configuring container backup jobs, Vault now prompts for the host's appdata root paths (defaulting to
/mnt/user/appdataand/mnt/cache/appdata). Container bind mounts outside the configured appdata directories (such as bulk/mnt/user/mediastorage) are automatically excluded by default to avoid unintended multi-terabyte archives, while Docker named volumes and appdata mounts remain protected. Users can inspect excluded mounts and toggle them back in at any time. Closes #317. -
User override for auto-skipped shared-data mounts (#307): Added an explicit toggle in the container job editor allowing users to include bind mounts that Vault automatically skips as shared or media data, accompanied by clear warnings regarding backup duration and storage consumption. Closes #307.
-
Periodic full backups alongside differential and incremental chains (#322): Jobs using differential or incremental backup strategies can now configure an independent periodic full backup schedule (e.g.
@weeklyor custom cron). When due, the runner forces a full base archive and establishes a fresh chain root, preventing unbounded chain lengths and bounding restore replay depth. Closes #322. -
Custom cron expression schedule picker (#309): Added a "Custom" schedule mode to the Schedule Picker across job creation, job editing, and periodic full backup configuration. The field validates standard 5-part cron syntax with real-time cadence descriptions and prevents invalid schedules from being saved. Closes #309.
-
Post-backup chunk verification for deduplicated jobs (#382): Deduplicated backup jobs with
verify_backupenabled now immediately read back and hash all written chunks upon session completion. Verification status is recorded in restore point metadata and surfaced in the job run results, guarding against silent backend write corruption before staging files are cleared. Closes #382.
Changed¶
-
Go toolchain and dependency updates (#437): Updated Go from 1.26.5 to 1.27.1 across module configuration, CI workflows, contributor docs, and the Docker builder image (
golang:1.27-alpine). Adopted Go 1.27 standard library features (uuid,strings.CutLast) andgo fixmodernizers (range n,slices.Contains,strings.SplitSeq/FieldsSeq,sync.WaitGroup.Go,atomic.Int64/Bool,errors.AsType,testing.T.Context,min/max,strings.Cut/CutPrefix), replacing the directgithub.com/google/uuiddependency with the standard libraryuuidpackage, and updated Go and web npm dependencies to their latest releases. Closes #437. -
Unflatten file contents and modernize selection in restore UI (#323): Step 3 of the Restore Wizard now presents archive contents as an expandable, collapsible hierarchical tree rather than a single flat list. Directories lazily render their child nodes when expanded, maintaining fast DOM performance even with backups containing thousands of files. When contents load, all files and folders are selected by default, inverting the checkbox semantics so that checking an item explicitly includes it and unchecking explicitly excludes it. Folder checkboxes provide tri-state indication (
checked,indeterminate,unchecked), searching automatically expands matching branches and highlights relevant items, and "Select all", "Deselect all", "Expand all", and "Collapse all" controls offer convenient batch operations. Ambiguous "Clear to restore everything" messaging has been removed, and selecting 0 files blocks execution with an informative warning to safeguard against accidental whole-archive restores. Closes #323. -
Restore wizard page 2 timeline, size, and confirmation UX improvements (#332): In Step 2 of the Restore Wizard, clarified backup sizes by displaying explicit "Restore:" and "Backup:" labels instead of the ambiguous "selected" suffix, updated item counts to use type-specific nouns (e.g. "containers", "VMs", "flash drives") when the selection is homogeneous, simplified daily group headers to display aggregate sizes directly, added intermediate date labels to the activity density graph, added an introductory overview paragraph explaining the version list, and upgraded restore point deletion to an expanding two-click "Delete?" confirmation badge with descriptive accessibility labels. Closes #332.
-
Restore wizard page 1 UI and item selection improvements (#327): Reorganized Step 1 of the Restore Wizard with alphabetical item sorting by default, quick search filtering across labels and job names, distinct filter chips and dedicated icon badge for Unraid Flash Drive backups, compact card layouts with uppercase type badges, and a sticky bottom floating action bar with active selection counts so operators can proceed immediately without scrolling long item lists. Closes #327.
-
Storage scan and import support encrypted manifests (#325): Scanning a storage destination now automatically decrypts deduplicated manifests using the local server key and identifies age-encrypted manifests. When age-encrypted manifests are detected, the Import modal displays an encryption notice with a passphrase field and unlock button, allowing users to decrypt metadata and import backups securely. Closes #325.
-
Stage classic folder backups beside the selected local destination (#366): Classic folder backups targeting local storage now stage temporary archives directly in a
.vault-stagedirectory on the target destination rather than defaulting to the first available cache pool. This eliminates out-of-space (ENOSPC) failures when backing up shares larger than cache pool free capacity. The staging folder is automatically excluded from recursive source walks. Closes #366. -
Dropped job-run-ID prefix from backup run folders (#319): Backup run directories in classic storage destinations no longer include the internal auto-incrementing job run ID in their folder names, standardizing on
<timestamp>_<type>(e.g.2026-09-15_143000_full). Historical restore points formatted with the previous<run_id>_<timestamp>_<type>convention remain fully discoverable and restorable without manual migration. Closes #319.
Security¶
-
Harden restore paths across VM, container, folder, and plugin restores against path injection: Validates restore destinations and file paths against traversal sequences (
../,..\) at the HTTP boundary inRestorePointPreflightandRestore. In the restore engines (VM disk/NVRAM restores, container volume restores, directory extractions, folder restores, and plugin restores), replaces direct directory creation withmkdirRestoredenforcing symlink rejection and traversal validation, enforces atomic descriptor-relative directory traversal withopenNoFollowon file creations, validates relative symlink targets within extraction boundaries while preventing writes through escaping links, and wraps modification time updates inapplyModTimeFromTimeguarded byrestorePathSafe. This addresses all 24 open CodeQL alerts (alerts 23, 24, 65, 77–97,go/path-injection). -
Sanitize and validate target paths before setting modification times on restored files:
FolderHandler.RestoreChunkednow validates and normalizes destination directories throughnormalizeRestorePathupon entry, validates that all restored directory and file paths remain strictly contained within the destination root and free of path traversal sequences, and wraps modification time updates inapplyModTimeguarded byrestorePathSafe. This addresses CodeQL alert #74 (go/path-injection). -
Encrypt manifest.json at rest (#325): Backup run manifests (
manifest.json) stored on destinations now have their metadata encrypted at rest. For deduplicated destinations, manifests are encrypted with AES-256-GCM using the destination's master key. For age-encrypted jobs, manifests are encrypted with age using the job's passphrase. This prevents unauthorized inspection of job configuration, protected item lists, container names, and paths on untrusted remote storage. Existing unencrypted manifests remain fully discoverable and readable without migration. Closes #325.
Fixed¶
-
Container volume archives correlate by mount source host path (#352): Classic container volume archives are now named
volume_<sha256(source)>.tarkeyed stably to the mount source host path rather than the fragile position index ininspect.Mounts. This prevents silent data corruption and cross-volume file unioning during differential restores when Docker alters the inspection mount order following container recreation or Unraid template updates. Existing archives named under the legacy positional convention remain restorable. Closes #352. -
Container chain restores no longer resurrect deleted files (#345): Classic container differential and incremental chain restores now perform a post-restore prune pass against each volume's destination, matching the folder restore behavior from #231. Using the newest restore point's authoritative effective listing and earlier tar index sidecars, files deleted from a container volume after the base full backup are pruned from disk rather than resurrected by the chain overlay. Closes #345.
-
Symlinked container volume sources follow symlinks before stale-mtime detection (#353): Container mount sources that resolve through symlinks (such as
/mnt/user/appdatalinks pointing to/mnt/cache/appdata) now evaluate their target before checking timestamps against parent effective listings, ensuring new files with older mtimes are reliably detected and archived in differential runs. Closes #353. -
Plugin backups now honor incremental and differential backup types (#351): Plugin items in classic and deduplicated jobs now respect the job's backup type. Differential and incremental runs evaluate
changed_sincecutoffs and parent effective listings instead of always taking full uncompressed archives, reducing backup sizes and elapsed time for systems with large plugin configurations. Closes #351. -
Unreadable files during archiving are skipped with a warning instead of failing the job (#393): Bad blocks or unreadable files (such as flash drive corruption or permission errors) encountered during tar archiving are recorded as skipped files and reported in the job run log as non-fatal warnings, allowing the remainder of the backup to complete. Skipped paths are excluded from effective listing sidecars so subsequent runs retry them. Closes #393.
-
Restore clean destination replaces target tree instead of merging (#321): When "Clean destination directory" is enabled, restores delete existing files in the target directory prior to archive extraction, matching the user-facing contract. The option is safely disabled for partial restores where only a subset of files is selected, preventing accidental deletion of unselected files. Closes #321.
-
Container template synchronization on volume remapping (#336): Restoring a container to an alternate destination path now synchronizes the container's Unraid
template.xmlin addition to the live Docker container, preventing Unraid's Docker GUI from reverting the container's path mappings back to the original location during template edits. The restore wizard now requires explicit confirmation before executing a volume remapping. Closes #336. -
Deduplicated container backups capture and restore Unraid template.xml (#379): The deduplicated container backup path now saves the Unraid container template under the synthetic key
__templateand restores it alongside container recreation, achieving parity with the classic container engine. Closes #379. -
Deduplicated backups capture single-file container bind mounts (#380): Single-file bind mounts in container configurations are now correctly chunked and restored under
__volfile__manifest keys, preserving file permissions, modes, and timestamps. Socket and named pipe bind mounts (such as/var/run/docker.sock) are cleanly skipped with descriptive skip reasons in the restore point metadata. Closes #380. -
Classic plugin restores honor custom restore destination (#381): The classic plugin restore path now extracts files to the user-specified restore destination override rather than overwriting live plugin configurations under
/boot/config/plugins/. Closes #381. -
Containers with zero bind mounts back up cleanly (#305): Containers configured without bind mounts now produce valid configuration and template restore points without failing or displaying confusing "no mounts detected" warnings in the job editor. Closes #305.
-
Ansible deployment SSH connection multiplexing (#410): The Ansible deploy role now multiplexes delegated
scpuploads over a single authenticated SSH control master connection, preventing Unraidsshdrate limiting and connection drops during plugin deployment. Closes #410. -
One absolute symlink aborted the whole restore, so a full restore of images like MySQL always failed: The classic extractor rejected any archived symlink whose target was absolute, and did so with a hard error that stopped the entire extraction — a restore of a container whose volume held even one such link failed outright with
unsafe symlink in archive. The rejection treated an absolute target as an escape attempt, but a volume archive is a snapshot of the container's filesystem:/var/lib/mysql/xinside a volume mounted at/var/lib/mysqlpoints back into that same volume once the container runs again, and MySQL's data directory ships exactly these links. Absolute targets are now recreated verbatim, preserving what the container expects. Writing through such a link is still refused: every archive entry already resolves its real parent chain on disk before extraction, so an entry addressed via a symlink that leaves the destination is rejected there, and nothing is ever created outside the restore directory. Relative targets are validated exactly as before, including chained..escapes through previously-extracted links — but a link that fails validation is now skipped with a log line rather than aborting the run, since refusing to create it is the entire safety property and killing the restore over one entry only adds data loss. Symlinks are also archived with their owner now — they were written with a hand-built tar header that carried no uid or gid, which would have restored every link as root-owned. -
Restores gave every file back to root, and a container restored into a new folder could not start: Neither restore path put back the ownership or the permissions a backup captured. The daemon runs as root, so a restored file was created root-owned, and a mode was applied only when the extractor happened to create the file — restoring over an existing tree kept the old permissions, and the mount's own root directory, which no tar archive or dedup manifest describes, was invented as root-owned
0750. On Unraid, where appdata belongs tonobody:users, that is enough to stop a container from reading its own configuration: a MariaDB container restored into a fresh destination died onCan't read dir of '/etc/mysql/conf.d/' (Errcode: 13 "Permission denied")while the restore itself reported success. Both the classic and the deduplicated path now restore the numeric owner and the recorded mode for every file, directory, and symlink, and both record the volume root's own mode and ownership at backup time — involumes.jsonfor classic backups and on the__vol__manifest entry for deduplicated ones — so a volume restored into a directory that did not exist yet comes back as the directory it was backed up from. Directory modes are applied after the files inside them are written, which also fixes restoring a read-only directory: its contents used to fail with a permission error because the directory was created read-only before its children were extracted. Ownership is best-effort and never fails a restore — an unprivileged daemon, or a destination filesystem with no ownership model, logs one warning and restores the data regardless — and a backup taken before this metadata existed restores exactly as it did before. -
Container restore ignored the file picker and restored every volume (#275): Selecting a handful of files from a container backup restored the container's volumes in full — every file in every mount, overwriting whatever was on disk. The picker's selection was carried as far as the restore request and then dropped: the classic path untarred each volume archive whole, and the deduplicated path handed each volume's sub-manifest to the folder restorer with no filter. Both paths now translate the selection — container-internal absolute paths such as
/config/settings.yml— into per-volume relative paths, extract only those files, and skip entirely any volume the selection never names, so an unselected mount is left untouched rather than restored and then partially overwritten. Selecting a mount point itself still restores that whole volume, a selected directory brings any mount nested beneath it along, a file inside a nested mount is taken from the deepest mount that holds it rather than from its parent, and a restore with no selection is unchanged.
Making this work required fixing the classic listing it depends on. Browsing a non-deduplicated container listed the item directory and returned the first readable *.index.json it found, so a multi-volume container showed one arbitrary volume's files under bare volume-relative names, with no indication of which mount they belonged to and nothing to map a selection back onto. The classic listing now reads the backup's volumes.json and merges every backed-up volume's index sidecar into a single listing in container-internal absolute paths — the same vocabulary the deduplicated listing already used, so the picker and the extraction filter cannot drift into different spellings of the same file. Single-file bind mounts list as their mount destination, a volume whose sidecar is missing or corrupt is omitted rather than failing the whole request, and a container backed up before volumes.json existed still falls back to the previous single-archive listing. Closes #275.
-
History and the dashboard showed every configured container as backed up, even when nothing had changed (#326): A differential or incremental run always wrote the container's
config.json, so an item whose volumes, image, and template were all unchanged still appeared in the run's per-item results with a green tick — indistinguishable from a container that had actually been re-copied. The engine now reports when a run captured no changed content (the classic path tracks whether it wrote a volume archive, image, template, or database dump; the deduplicated path compares the manifest it just wrote against its parent, ignoring the always-rewritten__inspectblob), and the run log records it per item. History and the activity timeline render those items with a neutral dash and anunchangedbadge rather than a success tick, and the run's item counter reads3/3 containers (2 unchanged). The item's status staysokand it remains restorable from the restore point through its chain — nothing about what is backed up or restorable changes, only what the history claims happened. Closes #326. -
The restore file picker displayed "No files match" when an item had no files at all: With the container picker no longer emitting engine metadata, a container whose volumes were every one of them excluded, anonymous, or virtual now correctly lists nothing — but the picker rendered the empty-filter message, telling the user their filter matched nothing when there was nothing to filter. It now says the backup captured no restorable files, and for containers explains that restoring still recreates the container from its saved configuration and image.
-
Database-dump containers leaked two more metadata entries into the restore picker: A container item with
database_dumpenabled stores its logical dump under the synthetic manifest keys__dbdump__and__dbdump_replay__. These were not on the synthetic-key list, so the picker listed__dbdump__as a file and__dbdump_replay__— a zero-value marker — as a spurious0 Bentry. Both are now recognised as engine metadata and dropped, completing the #333 fix for database containers. -
Container restore file picker showed wrong sizes and excluded volumes (#333): Browsing a deduplicated container restore point listed engine metadata as if it were files, showed volumes the job had excluded, and rendered sizes as
-1 B,0 B, orNaN. A container's dedup manifest holds only synthetic keys —__inspectand__image_metaare internal metadata, and each__vol__<destination>is a pointer whose real file entries live in a nested per-volume sub-manifest — but the picker emitted those top-level keys verbatim, so pointer and sentinel sizes were displayed as file sizes. The picker now drops synthetic keys, drops skipped and excluded volumes without dereferencing them, and expands each backed-up volume from its sub-manifest with container-internal absolute paths (for example/config/settings.yml). The synthetic-key rules are applied only to container items, so a folder or plugin that happens to hold a path named__inspector__vol__/...is listed verbatim. Closes #333. -
An item that backed up nothing was recorded as having no size at all: The backup metadata writer skipped any item whose size was 0, so "backed up nothing" was indistinguishable from "size never reported". Restoring only such an item then hit the legacy fallback and reported the whole restore point's size. Zero is now written explicitly; only a missing or non-numeric size counts as unknown.
-
Restore runs reported the whole backup's size instead of what was restored (#334): Restoring a subset of a backup — one container out of twenty — showed the entire restore point's size as the "restored amount" on the history and dashboard pages. The run size, completion broadcast, activity entry, and run summary now sum the per-item sizes recorded at backup time, and each item's restore log entry carries its own size. Restore points that record no per-item sizes — those written before this metadata existed — fall back to the restore point total and log that they did, rather than reporting zero; a run in which no item succeeded stays at zero. The history item counter is now type-aware, reading
3/3 containersinstead of3/3 items, with mixed-type runs keeping the generic noun. Closes #334.
[v2026.09.00] - 2026-09-01¶
Added¶
-
Backup and restore progress overlay now shows the active item being processed (#368): The "Backup in progress" and "Restore in progress" overlay on the Dashboard previously showed only the completed/total item count and elapsed duration. It now displays the name and type of the container, VM, folder, or plugin currently being processed (for example
Backing up: nextcloud (container)), updating in real time across backup phases, deferred uploads, and reconnect resyncs. Closes #368. -
Display real plugin names and filter hidden files (#329): Plugin discovery now extracts readable plugin names from
.plgDOCTYPE entity declarations and root XML attributes, rendering human-friendly display names across the item picker and job configuration while keeping internal identifiers intact. AppleDouble files (._*) and other hidden files are now automatically filtered out from discovered plugin items. Closes #329. -
Restore points now show the base full backup for chained restores (#318): Differential and incremental restore points in the restore timeline and wizard now display the date and size of the root full backup they depend on, and the
Chain ×Nbadge includes an explanatory tooltip describing chain length and replay behavior. Closes #318. -
Configurable log level (#346): Added a global log level setting (
debug,info,warn,error) configurable via the web Settings page and the settings API (log_level). Daemon logging routes through a centralizedsloghandler with dynamic level adjustments applied at runtime without restarting the daemon. Closes #346. -
Codecov configuration and dual-stack test coverage: Added repository-root
codecov.ymldefining dual-stack coverage flags (backendandfrontend), subsystem component dashboards, and an 80% coverage target on PR patches. Added@vitest/coverage-v8frontend coverage reporting with LCOV output, unified CI web test runs, and updated Go coverage targets with cross-package atomic instrumentation. -
Replication targets now show what the last sync actually did. A target that connected but had nothing new to copy previously just read "Synced", which looked the same as a sync that transferred data — so replication could appear to "connect but not do much". Each target now shows an explicit Up to date state when a sync found nothing new, a per-sync summary (jobs synced, new restore points, bytes transferred, and any failures), and the time replication last fully succeeded. These counters are stored with each run, so they are visible on page load and after a refresh, not only in the moment a sync finishes. Closes #287.
-
Run logs (#328): backups and restores now write a real streaming log — run start, per-item start/success/failure with sizes and durations, phase changes (staging, deferred upload, stop-all), stalls, and an end-of-run summary (items, duration, percent successful). In the Logs tab, any backup or restore entry now expands to show its run's log in a scrollable box that appends in real time while the run is live. Entries persist with the run and are cleaned up with run history, backed by a defensive 10,000-line-per-run cap alongside the existing activity-log cap. Storage health checks now record per-destination outcomes in the activity log. New API:
GET /api/v1/runs/{runId}/logs?after=<id>; new MCP tool:get_run_logs; new WebSocket event:run_log. Closes #328.
Security¶
-
Dependency refresh across the Go and web stacks. Updated all Go modules to their latest compatible releases (AWS SDK v2 S3/config/credentials/STS,
go-libvirt,gzfs, OpenTelemetry 1.46.0,miekg/dns,moderncSQLite support libraries) and the web dependencies (svelte5.57.0,eslint10.9.1).go mod tidyalso dropped four modules that were no longer required.gosecreports 0 issues,govulncheckfinds 0 called vulnerabilities,npm auditfinds 0, and the full backend and frontend suites, linters, and production build pass unchanged. -
Cleared vulnerability advisory in build-time dependency. Bumped
golang.org/x/modtov0.40.0(andgolang.org/x/toolstov0.49.0) to clear CVE-2026-56864 insumdb. The package is a build-time dependency, is not reachable from Vault's own code, and there is no runtime behaviour change. Verification viamake security-check(gosec, govulncheck, andgo mod verify) is clean with 0 vulnerabilities and all modules verified. Closes #367. - Updated Go and web dependencies to their latest compatible releases and resolved a high-severity advisory in the web
nanoiddependency. gosec, govulncheck, and the Go linter pass clean, and the MCP integration remains on the go-sdk v1.7.0 API.
Changed¶
-
The Logs console now shows the newest entry first. Logs were listed oldest-first, so the entry you almost always want — the one that just happened — was at the very bottom, and reading it meant scrolling through the entire history first. The console is now inverted: the newest line is at the top, scrolling down reaches further back, "Jump to present" returns to the top, and the "End of logs" marker sits at the bottom where the oldest entry is.
-
Log lines are colour-coded by level and far less cluttered. Every row used to render in the same two tones, so an error and a routine info line looked alike, and each row spent a third of its width repeating the same date. Errors, warnings, info, debug and success now each have their own left-border colour, level pill and row tint — always paired with a text label, so the console stays readable in the monochrome themes and for anyone who cannot distinguish the hues. Rows now show only a time, with a sticky day separator naming the date and counting that day's entries; the full timestamp is still in the tooltip. Runs of identical consecutive lines fold into a single row with an
×Ncount, and thekey=valuemetadata tail is hidden behind a new Details toggle. Both Collapse and Details can be switched off to get every line back verbatim, and copying a folded row still yields every line it stands for. -
Contributor setup docs now include the local build, test, lint, and deploy workflow. The repo-root contributor guide now documents the required toolchain, bootstrap steps, local development loop, daemon build/run path, frontend dev flow, and the safe boundaries around
make deploy/make verifyso new contributors can get working without digging through the README or the codebase. This keeps the contributor guidance in the canonical repo-root location while leaving the README focused on project overview and installation. -
The
vault mcpcommand has been removed. It started a second process that wrote directly to the running daemon's database from outside it, so anything it changed bypassed the scheduler, the flash-storage flush, and the live UI updates — which is what made assistant-created jobs silently never run. AI assistants should connect to the daemon's/mcpendpoint instead, which exposes the same tools with all of those protections in place. -
A scheduled deep verify no longer piles onto a destination that a backup is still writing to. Deep verification streams and re-hashes every stored item, so running it against the same storage destination as an in-progress backup saturated the shared disk and network — the backup slowed to a crawl and looked frozen, with nothing in the logs to explain it. Vault now defers a scheduled deep verify while a backup is running on the same destination and records why in the activity log, letting the deep verify run at its next scheduled tick instead. Quick verification (existence and size checks only) is light enough to keep running alongside a backup, and an on-demand verify you trigger yourself is never deferred. Closes #290.
-
Anomaly alert summaries are now concise and name the job or destination they refer to. The duration, size, reliability, and capacity anomaly summaries previously opened with a generic "This backup" or "Storage …" and folded raw figures into the message itself. Each now leads with the affected job or destination name and states the change as a plain percentage (for example
"media-backup" duration increased by 233%.), while the exact per-day rate, byte counts, and growth factor stay in the anomaly's investigation details. The direction wording always matches the numbers — a high-side rule that fires on a slightly-smaller-than-usual run now reads "decreased by" instead of contradicting its own figures. Closes #315.
Fixed¶
-
Anomaly pills on Jobs and History now separate warning and critical counts with hover tooltips. Jobs and backup runs with open anomalies previously collapsed all issues into a single pill displaying the worst severity, masking when a job had both warnings and critical errors, and rendered with an ambiguous circle dot with no explanation of what the pill meant. The Jobs and History pages now render distinct pills for each active severity level (critical, warning, info) with a clear warning-triangle icon, wrapped in interactive tooltips explaining the anomaly breakdown and how to inspect or resolve them. Closes #330.
-
Folder backup jobs now display the full folder path. When configuring folder backup jobs or viewing configured folder targets, only the folder basename was shown (for example
appdatainstead of/mnt/user/appdata), which obscured vital path context and made it impossible to distinguish between folders sharing the same name. The full path was already stored in item settings and IDs but was not rendered by the interface. Folder items across the Item Picker, exclusions, Dashboard protection widget, Restore Wizard, and History views now show their full path with hover tooltips, while discovered presets retain their friendly names. Closes #335. -
Restoring a renamed or newly added item no longer fails when an older chain step predates the item. When restoring an item from a backup chain where older restore points were created prior to the item being introduced or renamed, staging failed outright with a
no such file or directoryerror because the item's subdirectory did not exist in those older chain steps. Vault now treats not-found errors (fs.ErrNotExist) during item directory listing as missing restore data for that step, logging a warning and gracefully skipping to replay subsequent chain steps that contain the item data. Normalisation of missing directory errors tofs.ErrNotExistwas also extended to SFTP and SMB storage adapters. Closes #355. -
The Restore wizard restore button now recovers if the completion notice is missed. When a restore completed while the WebSocket was disconnected or the tab was in the background, the completion message was lost and the local
restoringflag stayed set, leaving the button grayed out on "Restoring…" even after the user notification had fired. The wizard now reconciles against the/runner/statusendpoint on mount and handlesrunner_status_snapshotevents on WebSocket reconnect, re-enabling the restore button once no matching restore is active. Closes #356. -
Classic (tar) folder and container restores now preserve original file and directory mtimes. Restoring from classic archives previously wrote files and directories with the restore-time timestamp instead of their archive mtime, causing a fidelity gap with the deduplicated restore path and making subsequent differential or incremental backup runs treat unchanged files as modified. All classic restore callers now restore the archive mtime on regular files, hardlinks, and directories. Closes #354.
-
Clicking Run Now updates the job row instantly, with no page flash. Triggering a job used to blank the Jobs table into loading skeletons and swap the row's Run Now button only after a WebSocket round trip — a visible "refresh" in place of a simple click response. Background refreshes now keep the existing table on screen and update the keyed rows in place, and the clicked row flips to its Cancel control the moment the API call succeeds, with the real run details arriving over WebSocket moments later. A background watchdog reconciles the running state against the runner status on a short interval, so a dropped WebSocket event — even both the start and completion of a long-running job — can never leave a stuck Cancel button. Complements the cancel-button behaviour from #235/#238.
-
The Jobs page Cancel button now clears as soon as the run ends, without waiting for the next refresh. Whether a job showed Cancel or Run Now was derived from the live progress state, which kept the finished run listed as active until the next poll replaced it — so a completed job could sit with a Cancel button that did nothing. The running flag is now cleared the moment the run-completion event arrives (guarded against stale or out-of-order completion events), and the running/queued decision lives in one tested helper shared by the page. Complements the cancel-button behaviour from #235/#238.
-
Newly added items to an existing backup job now perform a complete capture on differential and incremental runs. When a container, VM, or folder was added to a pre-existing job that had already produced restore points, differential and incremental runs inherited the parent restore point's timestamp as the item's
changed_sinceboundary. For containers, Docker images created before the job's last run were omitted from the archive (image.tar.zstmissing); for folders, files whose modified timestamps predated the parent restore point were skipped. Differential and incremental backups now check whether the specific item was captured in the parent restore point, taking a full capture when an item is newly introduced to the job and skipping uncaptured historical steps during restore. Closes #310. -
The Recovery page no longer lists a container, VM, or folder once per job that covers it. When the same item was protected by more than one backup job, the disaster-recovery plan appended it once per job, so the restore steps showed duplicate entries, double-counted sizes, and repeated warnings. The plan now merges items across jobs by type and name: an item counts as protected if any job has a restore point for it, the displayed backup date, size, and storage come from the job with the newest restore point, folder presets are preserved, and each item is counted and warned about exactly once. Closes #314.
-
Backup jobs created or changed through an AI assistant now actually run. The MCP tools wrote jobs straight to the database without telling the scheduler, and the scheduler only reads jobs when the daemon starts. A job created through an assistant was saved, appeared in the UI with its schedule, and then silently never fired — until the next daemon restart. Changing a schedule and deleting a job were stale in the same way, so a deleted job could keep firing. All three now reload the scheduler, flush to flash, and notify the UI exactly as the web console does.
-
The MCP tools now validate input instead of saving whatever they are given.
create_jobandupdate_jobaccepted any value for compression, encryption, container mode, VM mode and notification settings, and accepted schedules the cron parser cannot read and storage destinations that do not exist. Bad values were stored verbatim and only failed much later inside the backup engine, far from the action that caused them. Both tools now apply the same checks the web console applies, and report the same messages. -
Deleting a job through an AI assistant no longer interrupts a restore in progress.
delete_jobremoved the job's rows immediately, with none of the protections the web console has: it did not refuse while a restore for that job was mid-write (which both corrupts the restore and erases its records), and it did not stop a running backup first, leaving the backup writing to storage after its job was gone. It now behaves exactly like deleting from the web console, and takes an optionaldelete_files— defaulting to off — to also erase the job's backup data. -
A job created through an AI assistant now gets the same defaults as one created in the web console. Each entry point carried its own copy of the default backup chain and retention policy, and they disagreed, so two identical-looking jobs could keep a different number of restore points for a different number of days. This was invisible until the retention sweep removed the wrong backups.
-
Incremental and differential backups now include new files even when those files have older mtimes than the parent restore point. Files copied in with preserved timestamps (for example via
cp -a) were previously skipped as "unchanged" in both classic and deduplicated paths, which could silently drop data from later restore points. Vault now uses parent effective listings to detect these new paths, falls back to a safe full archive when parent listing data is unavailable, and preserves unchanged base files when restoring classic container chains. Closes #320. -
A container's database dump is no longer lost when the restore does not reload it. When a restore left the SQL dump in place — because the restored volume was the newer, consistent copy, the container was not running, or the database never became ready — the dump only ever existed in a temporary staging directory that Vault cleaned up when the restore finished, so there was nothing left for an operator to find. Vault now copies the un-reloaded dump to a durable, discoverable location and reports the exact path in the restore progress and logs: the custom restore destination when one is set (
<destination>/<container>-database.sql), otherwise beside the container's first restored volume (for example/mnt/user/appdata/<container>-database.sql). The file name is prefixed with the container name so restores that share a parent directory do not collide, and the container backup guide now documents where the dump lands for in-place and custom restores. Closes #289. -
The web console no longer gets stuck on an endless "connecting" screen after a plugin upgrade until you clear site data. The page that the Unraid shortcut opens (
app.php) served the app shell with no cache headers, so a browser — Brave especially — could keep a copy of it from before an upgrade. That stale shell still pointed at the previous JavaScript bundles, which the upgrade had replaced with new content-hashed filenames, so the browser fetched bundles that no longer existed and the app never finished loading. The shell is now sent with the same "never cache" policy the daemon already applies to its ownindex.html, so every visit loads the shell that matches the installed version. Closes #288. -
The Dashboard "Largest backups" tile no longer shrinks after incremental backups run. It was computed from only the last handful of runs, so once a job's large full backup dropped out of that short window the tile fell back to the size of the most recent — much smaller — incremental. It now reads the largest backup per job from the latest 200 history records, so the full backup remains represented well beyond the short recent-runs window. Closes #286.
-
A custom restore location is now honoured when restoring a plugin from a deduplicated destination. The deduplicated plugin restore only looked at the restore directory passed to it directly and ignored the
restore_destinationsetting as a fallback, unlike the container restore path which already checks both. In current use both are set to the same value so there was no visible effect, but the two independent paths could have drifted apart in future. The plugin restore now falls back to the setting when no directory is passed, keeping the two in step. Closes #274. -
A plugin backed up to a deduplicated destination now keeps its
.plginstaller file. The deduplicated plugin backup only stored the plugin's config directory, leaving out the.plginstaller at/boot/config/plugins/<name>.plg. A restore from such a backup — for example onto a fresh Unraid install — brought the configuration back but not the installer, so Unraid might not recognise the plugin on the next boot. The.plgis now stored alongside the config in the same restore point and written back on restore, matching what the non-deduplicated plugin backup already did. Backups taken before this change simply have no installer to restore, exactly as before. Closes #273. -
Restoring selected files from a plugin backup on a deduplicated destination no longer restores the whole plugin. When a plugin backup lived on a dedup destination and you picked specific files to restore, the file-picker selection was dropped on the way through the deduplicated restore path and the entire plugin config directory was written back instead. The selection now carries through to the folder restore that reconstructs the files, so only the chosen files are restored — matching how the same picker already worked for non-dedup plugin restores. Closes #271.
-
Backup sizes now read the same everywhere they are shown. Four operator-facing places still formatted sizes with their own math instead of the shared byte contract, so the same backup could read differently depending on where you looked: the Storage page's saved-backups list stopped at TB with no clamp (a petabyte showed as
1024.0 TB), the Recover Vault wizard always printed raw kilobytes (1048576 KBfor a gigabyte), the packaged plugin History page always printed megabytes (1073741824.0 MBfor a petabyte), and the transfer-speed label capped at GB/s and disagreed with the app near unit boundaries. All four now go through the sameformatBytescontract the app and notifications already use, so a size or speed reads identically across the console, the wizard, the plugin pages, and Discord/webhook messages. Closes #270.
[v2026.08.00] - 2026-08-02¶
Added¶
- Database containers can now be backed up as a proper database dump, not just its files. PostgreSQL, MySQL, and MariaDB containers get an optional "Also save a … dump" tick in the job editor. Vault exports the databases with the server running and stores the dump alongside the usual file backup — copying a database's files while it is writing can capture a torn state that will not load, whereas a dump is always consistent and can be reloaded into a different server version. The dump covers the whole server including users and permissions, so a restore does not come back missing the accounts the application signs in with. It uses the credentials already set on the container, so there is nothing to configure and no second copy of a password for you to look after. If the container exposes no usable root password, the dump is scoped to the database named in its environment instead, since an application user cannot read the whole server. A container that is stopped or stuck restarting is reported plainly rather than silently skipped.
Restoring puts the folders back and leaves the dump alongside them for you to reload by hand or into a different server version. It is deliberately not replayed over the restored folders: the dump is taken while the server is still running, the folders are copied after it stops, so the folders are the newer copy — and a reload would drop anything written while the dump was still being taken. Where the folders were read from a running server, and so may be mid-write, the dump is the trustworthy copy and is reloaded automatically; if that reload fails the restore fails with it, rather than handing back a database that looks restored and is not. Off unless you tick it. Closes #259.
- Exclude the same paths everywhere, without editing every job. Settings → Backup Exclusions takes a list of paths that every folder and container backup skips. They are added to whatever a job already excludes rather than replacing it, so a job can still exclude more of its own. Useful for the paths that are never worth backing up anywhere — caches, sockets, scratch directories. Closes #257.
- Containers can declare their own exclusions with a Docker label. Setting
vault.exclude=/config/cache,/tmpon a container tells Vault to skip those paths when backing it up. The exclusions travel with the container, so a template or compose file carries its own answer and adding the container to a job needs no further setup. The paths are matched exactly like ones typed into the job wizard, and the job editor shows which mounts a label is skipping. A label can exclude paths but not the whole container — a catch-all pattern is refused, since the label is written by whoever built the image rather than by the person who owns the backup. On by default; it can be turned off in Settings → Backup Exclusions. Closes #258. - Duration trend chart on the History page. The trend chart now has a metric selector — Backup size or Job duration — so duration drift can be watched over time the same way size drift already can. Duration is averaged per bucket and drawn with a slower/faster trend indicator. Closes #331.
Fixed¶
- A container backup now fails instead of quietly saving nothing when every one of its folders is excluded. With a broad exclusion rule — from the global list or a container's own label — it was possible for every backup-eligible folder to be skipped, leaving a backup that reported success but held only the container's configuration. It now stops with an error naming where to look, matching how virtual-machine backups already refuse to run when a VM has no disks they can handle. This applies to deduplicated destinations as well as ordinary ones: the check originally guarded only the ordinary path, so a dedup destination could still record a restore point holding no folder data at all — one that retention might later keep in place of a complete one. The check also runs before the container is stopped, so a backup that cannot produce anything no longer costs it any downtime.
- Large backups to a dedup destination no longer slow to a crawl and appear frozen. Every time a backup finished a 24 MiB pack, Vault listed the entire
_vault/index/directory on the destination to work out the next index-blob number. That directory gains one entry per pack, so the cost grew with every pack written: a 500 GB folder flushes roughly 21,000 packs and so issued 21,000 listings of a directory holding up to 21,000 entries — hundreds of millions of directory entries pulled across the network in a single run. Progress crawled, got steadily worse the longer a backup ran, and on a remote destination (WebDAV/SFTP/S3) eventually degenerated into connection and DNS timeouts that failed the run. Vault now reads that directory once per backup and counts on from there, so index bookkeeping costs the same on the last pack as on the first. Existing destinations are picked up as-is — no re-upload, repair, or reconfiguration is needed. Closes #256. - A slow or stalled browser can no longer hold up a running backup. Live progress events were handed to the WebSocket layer with a blocking send, so once the outbound queue filled — a browser tab left open that had stopped reading, or a flaky connection — the backup goroutine itself waited on it. Progress updates are now discarded rather than queued when a connection cannot keep up, since the next update carries the same information. Events the interface cannot recover by waiting — run completion, item failures, queue changes — are still delivered reliably, so a busy backup never leaves a finished job showing as still running.
- Backup runs no longer log a spurious "malformed settings JSON" warning. A job item saved without any per-item settings stored an empty value rather than an empty set, so every run that checked it reported the item as corrupt in the log. Nothing was actually wrong and backups were unaffected, but the warning pointed at a healthy item. Items now always store a valid value, and existing ones are read correctly without the warning. Genuinely corrupt settings are still reported.
- A VM that ignores the request to shut down no longer fails the backup. Cold VM backups ask the guest to shut down gracefully and wait for it. If the guest accepted the request but never acted on it — no ACPI handler installed, sitting at a login prompt, or an application blocking shutdown — the wait timed out and the backup gave up with "waiting for domain … to shut off: timed out with state running", even though Vault already had a forced-stop step ready for exactly this situation. It now falls through to that forced stop, so a stubborn guest is powered off and the backup proceeds. A backup you cancel yourself is still never turned into a forced power-off. The same fix applies when a restore has to stop an existing VM before replacing it. Reported in #255.
- "No space left on device" now explains itself on an Unraid user share. A backup writing to a path under
/mnt/usercould fail with a bare out-of-space error while the array showed terabytes free, which reads like a permissions fault or a Vault bug. It is neither: a user share spreads its contents across disks, but any single file is written to one of them, so a large VM disk image fails as soon as the disk it landed on is full — no matter what the array total says. The error now names the directory that filled and, for a user share, explains this and points at the settings that govern it (the share's Minimum Free Space and Split Level), or suggests targeting a specific disk or pool such as/mnt/cacheinstead. Reported in #255. - A dedup backup can no longer report success while leaving data that a later repair would discard. After writing a pack of deduplicated data, Vault recorded it in its local database before writing the matching record to the destination. If the second write failed, the backup still completed successfully, but the destination held data with no record of what was in it — so
vault dedup repair, the tool for rebuilding the local database from the destination, would quietly drop it and reveal the backup had been unrestorable all along. The destination record is now written first, so a failure fails the backup immediately and visibly instead of surfacing later as missing data. - A backup can no longer be built on top of data that space reclamation has already marked for deletion. When reclaiming space, Vault records its intent to remove a pack before deleting it, so an interrupted run can be finished later. If the deletion itself failed — a destination that is reachable but refusing deletes, for instance — the pack's contents stayed listed as available, so the next backup of the same files would reuse them instead of storing its own copy. That backup then depended on data already marked for removal, and repairing the index afterwards would drop it, leaving the backup unrestorable. Contents of a pack marked for deletion are now excluded from reuse the moment that mark is recorded, while the record itself is kept so the next reclamation retries the delete. The affected backup simply stores its own copy instead. Existing destinations pick this up automatically on upgrade.
- Repairing a dedup index can no longer lose chunks that a surviving pack still holds.
vault dedup repairrebuilds the index by replaying the bookkeeping records stored alongside your backups, and it applied deletion records as it went. Because a chunk is recorded once and space reclamation copies chunks into a replacement pack before retiring the old one, replaying the retired pack's record last left the chunk attributed to it — and applying the deletion then removed the chunk outright, even though the replacement pack still held the data. Restores after a repair could then fail with missing chunks. The rebuild now determines which packs were retired before it replays anything, so the result no longer depends on record ordering. This only affected repair and disaster recovery; normal backups and restores were never at risk. - The interface now recovers on its own after losing contact with the daemon. A browser that stopped keeping up — a background tab, a flaky link — was disconnected by the daemon, and any update in flight at that moment was lost. Since a job's completion notice is what clears the progress display, losing that one message left a finished backup shown as still running until the page was reloaded. On reconnecting, the console now re-reads the current run state and reconciles what it missed, so the display corrects itself. The daemon also states plainly that it dropped a connection for falling behind, instead of closing it in a way indistinguishable from a network failure.
- Per-file progress updates are now rate-limited to roughly one per second. Chunked backups and restores sent a WebSocket event for every single file — 46,000 events for a large music library — each one costing CPU on the server and work in the browser, for updates far faster than anyone can read. Backup, restore, and the restore download phase now report at a steady cadence instead. On-screen progress is unchanged in practice, and the internal stall detector still sees every file and every byte, so a genuinely hung backup is still detected just as quickly.
- Restoring a differential or incremental container from a dedup destination no longer fails with "no such file or directory". Only the full backup would restore; every later point in the chain stopped with an error naming a directory that does not exist. Dedup backups keep their data in the chunk store rather than in a folder per container, but container restores were being sent down the older path that looks for that folder — a shortcut for restoring a single point directly was in place but sat behind a check that only folders ever reached. Container and plugin backups write a complete record of the item on every run, exactly as folders do, so restoring the chosen point on its own reproduces it correctly. Restores from non-dedup destinations are unchanged. Closes #266.
- Saving a job in Batch container mode no longer fails with "invalid container_mode". Choosing Batch in the job editor and clicking Save returned an error and refused to save; Sequential saved normally. The job wizard, the backup runner and the rest of the codebase all call this mode
stop_all, but the API's list of accepted values had been written asall_at_once— a name nothing else used — so the one value the interface could actually send was the one value the API rejected. Batch mode was therefore impossible to select on any job. The list now matches the rest of the stack. Any job still holding the old value — it could only have been written by the MCP interface, an import or a replication sync, none of which check this field — is converted once, on upgrade, so every such job changes together rather than only the ones that happen to get edited. Those jobs had been running sequentially despite the name, and will now genuinely stop all their containers together. Closes #261. - A cold VM backup no longer fails outright and leaves the guest powered off when the guest is slow to stop. Vault asked the guest to shut down and then waited for it, but treated "shutting down" — a passing state on the way to being off, while the guest is still running — as though the guest had already stopped. When a poll happened to land in that window, the backup pressed straight on to start its temporary session and libvirt refused with "domain is already running", so the backup failed while the guest carried on shutting down and stayed off. Because it depended on the timing of a single poll it struck only sometimes, which made it look like a random failure rather than a bug. Vault now waits for the guest to be genuinely off. Reported in #265.
- A cold VM backup can no longer leave the guest powered off and the job stuck on "running" for hours. After a cold backup stopped a VM and copied its disks, the temporary paused session had to be torn down before the guest was started again. If that teardown failed, Vault returned immediately and never issued the start — so the VM stayed down, the run never finished, the job showed "running" indefinitely, and only restarting the daemon cleared it. The guest is now brought back up whether or not the teardown succeeded, and is resumed rather than restarted when the session is still paused, since a paused guest is just as unavailable as a stopped one. Both failures are reported instead of one hiding the other.
- A backup that stops responding is now reported as stalled instead of sitting silently on "running". Several problems combined to make a wedged run invisible. The stall watchdog stopped running the moment it cancelled a stalled job, so from that point nothing was written to the log at all — the operator saw a job stuck on "running" with a dead log and no way to tell a working backup from a jammed daemon. Underneath, calls to the virtualisation service could wait forever: they have no time limit of their own, so if libvirt accepted a request and never answered, no amount of cancelling could free the backup, and even opening the connection could hang before anything was watching it. A run that stops making progress is now marked as stalled, with the reason, and that is published to the interface — so a stuck run can be told apart from a healthy one rather than both reading as "running". The run keeps its real status until it genuinely finishes, so nothing is reported as failed that has not actually failed. The watchdog also keeps reporting, at widening intervals, for as long as a cancelled run refuses to finish. Connecting to the virtualisation service is now time-limited, and a cancelled run still stuck on it after a grace period has its connection dropped so the stuck call fails and the run can finish. Dropping it can no longer strand a virtual machine: the shutdown-and-restart step disconnects that safety net first and reconnects if it has already fired, so the guest is still brought back up. Reported in #265.
- Differential container backups no longer stop and restart containers that have nothing to back up. Every running container in the job was stopped and started again on each differential run, even when none of its folders had changed since the last full backup — pure downtime for no data. Vault now checks the folders first and leaves the container running when there is nothing new, applying the same exclusions as the backup itself so an excluded host-wide mount is never scanned. Full backups, containers marked not to be stopped, and containers that are already stopped all behave exactly as before.
-
Restoring a container to a chosen location no longer merges two of its folders into one. Where a restore sends data somewhere other than its original home, each folder lands in a directory named after the last part of its original path. Two folders whose paths differ only higher up —
/mnt/user/app/configand/mnt/cache/other/config, say — therefore both claimed the same directory: their contents were interleaved, files with matching names overwrote one another, and the rebuilt container was pointed at that one directory for both. The clash is now detected before anything is written and the restore stops, naming both folders, rather than reporting success over merged data. Restoring to the original locations was never affected. -
A restore no longer reports success when reloading a database dump failed part-way. The failure was written to the log and the restore finished normally, even though reloading a dump replaces the existing tables as it goes — so a reload that ran out of time, hit an error, or filled the disk left the database holding only part of its data while the restore said it had completed. Such a failure now fails the restore and says the database should not be relied on.
-
The daemon can no longer hang indefinitely while disconnecting from the virtualisation service. Closing a libvirt connection asked politely first and waited without limit for the answer, which never came if libvirtd had accepted the connection and then stopped responding — the exact situation the connection was meant to be able to break. Because the idle-check that runs before an adaptive backup holds the lock that serialises runs, one such hang stopped every later backup from starting. The polite close is now given five seconds before the connection is broken outright.
-
Restoring a container from a dedup destination to a custom location no longer writes volume data to the original paths while pointing the recreated container at the empty custom directory. The chunked restore path discarded the destination parameter and always wrote files back to the original bind sources, then recreated the container with mounts rewritten to the custom destination — so the running container was destroyed and replaced with one pointing at empty directories, while the data sat orphaned at the original paths. Volume data now lands under the custom destination (matching both the classic restore path and the container's rewritten binds), restoring parity between the two code paths. The classic restore path's volume-target computation is also extracted into a shared helper so the two can never drift apart again.
-
The Dashboard no longer contradicts itself on a fresh install. The Protected tile reported "All items covered" in green beside a red 0-of-N bar while every item was still waiting for its first backup — the caption counted only items missing from every job, and an item queued but never yet backed up is in neither group. It now reads how many are pending. The Health tile likewise announced "Attention needed — recent backups have not completed" on a server where nothing had failed and nothing had yet finished; a score of zero with no runs at all now reads "No backups completed yet".
Security¶
- Cleared a high-severity advisory in a build-time dependency and updated a bundled library.
brace-expansionin the web UI's dependency tree is updated to a patched release (denial of service via unbounded expansion), andgo.opentelemetry.io/otelmoves to 1.44.0 for an unbounded-header-length advisory. Neither is reachable from Vault's own code and no runtime behaviour changes. A full vulnerability scan of the Go module graph and the web dependency tree reports nothing else outstanding.
Changed¶
- The 8-Bit and 16-Bit themes now use fonts bundled with the plugin instead of downloading them from Google Fonts. Selecting a retro theme previously injected a stylesheet link to
fonts.googleapis.com, so on a server without internet access — or behind a firewall or DNS filter that blocks Google — the pixel fonts silently fell back to Courier New and the theme lost its character.Press Start 2PandVT323(both SIL OFL 1.1) now ship inside the web bundle and are served by the daemon itself, so every theme renders identically on an offline Unraid box and Vault makes no third-party network requests. The fonts are still fetched only when a retro theme is actually selected — the Default and 1-Bit themes download nothing. - Backup sizes in notifications now use sensible units instead of always megabytes. A completed-backup notification divided the total by a megabyte and printed it whatever the size, so a 2 TB backup arrived as "2097152.0 MB" — a number nobody can read at a glance. Sizes now pick their own unit, from bytes up to petabytes, in both the Unraid notification and the size and transfer speed in Discord messages. The code that turns a byte count into text had also been copied several times over and the copies had drifted apart, so the same 1.5 KB could read as "2 KB" in one place and "1.5 KB" in another. Notifications, backup progress, anomaly summaries, the main web console and the older plugin pages bundled with a release now share a single version of it. Some screens still have their own — the storage and recovery pages, the legacy history page, and the restore space check, which deliberately reports in KiB — and are unchanged here.
[v2026.07.10] - 2026-07-24¶
Fixed¶
- Cancelling a differential backup now takes effect immediately, even during the pre-scan of unchanged data. The
changed_sincepre-scan (pathChangedSince) walked a volume or VM disk tree to completion before honouring a cancel request, so cancelling a differential backup of a large, mostly-unchanged path could hang until the whole tree had been read. The scan now checks for cancellation between files — matching the rest of the backup engine — so container (classic and dedup/chunked) and VM differential backups stop promptly. As part of making cancellation prompt: a cancelled container backup now always restarts the container it stopped (the restart no longer rides on the cancelled request context, which previously could leave a running container stopped), and an operator Cancel is no longer occasionally misclassified as a stall and scheduled for an automatic retry. Closes #251. - Differential backups of containers that bind-mount the host root no longer scan the entire host filesystem before skipping it. On the classic (tar) path the
changed_sincepre-scan ran before the mount-exclusion check, so a differential backup of a container mounting/(Telegraf, Netdata, Glances, cAdvisor, node-exporter) walked all of/only to then discard it — the same runaway walk originally fixed in #70. The exclusion check now runs first, matching the dedup/chunked path. - Container backups to dedup/chunked destinations now stop the container first for a consistent snapshot. The chunked/dedup backup path read container volumes live without stopping the container, unlike the classic tar path which stops running containers (unless Don't stop is set). In-flight writes during the copy could produce an inconsistent or corrupt dedup backup. The chunked path now mirrors the classic behaviour: it honours the
no_stopsetting, stops a running container before reading its volumes, and guarantees the container is restarted afterwards — even if the backup fails partway through. - Differential backups to dedup/chunked destinations now skip unchanged data at the filesystem level. The chunked/dedup backup path did not honour the
changed_sincereference that the classic tar path already used, so an incremental run re-read and re-processed files (and whole container volumes) that had not changed since the last backup. Files and volumes older than the reference are now skipped in the chunked walk — matching the differential behaviour of the tar path — while directories are still recorded. A malformed or absentchanged_sincesafely falls back to a full backup. Closes #249. - The web UI no longer hangs forever on the loading spinner in Brave (and other privacy-hardened browsers). Brave's default Shields can make
localStorageandmatchMediathrow aSecurityError— most often when the console is embedded in the Unraid page as an iframe — which aborted theme setup during boot before the app ever became interactive, so the tab loaded endlessly ("Tab is not responding"). Every storage/media-query access during startup is now guarded and degrades to sensible in-memory defaults, and a boot watchdog guarantees the interface always becomes usable even if a startup step stalls. Closes #250.
Changed¶
- A connection banner now appears when the live link to the Vault daemon is lost. Instead of a silently frozen page, a banner explains that data may be out of date and offers a one-click reload, so a persistent connection problem is visible and recoverable rather than invisible.
- The live-update WebSocket now reconnects with exponential backoff and jitter (capped at 30s) instead of a fixed 3-second loop, so an unreachable daemon no longer churns the network and many clients reconnecting after a restart don't stampede in lockstep.
Security¶
-
Direct LAN access to the daemon now answers browser Private Network Access preflights. Chrome and Brave (130+/Edge 143+) block requests from a more-public origin to a private LAN/loopback address unless the daemon echoes
Access-Control-Allow-Private-Network: trueon the preflight. The daemon now returns this header alongside its existing CORS response, without changing the origin allow-list, so the Unraid-embedded and LAN-IP access paths keep working across browsers. -
Cleared three high-severity advisories in build-time dependencies.
brace-expansion(reachable through ESLint) is updated to a patched release in the web UI's dependency tree. The repository root also carried a second, unused npm project whose only declared dependency was theshadcnCLI — it was referenced nowhere, excluded from every build, yet still accumulated advisories (brace-expansion,js-yaml) that had to be patched. It has been removed rather than patched again. None of these packages ship in the daemon or the web UI, so no runtime behaviour changes.
[v2026.07.09] - 2026-07-21¶
Added¶
- Changing the database location now reports whether the database actually moved. Saving a new location previously confirmed only "Snapshot path updated", which said nothing about the data itself. Settings now reports the migration — for example "Database migrated to /mnt/garbage/vault.db — 5 files removed from /mnt/cache/.vault/vault.db" — and if the move could not be completed it says so as an error, naming the reason, so it is clear the database is still readable at its previous location.
Fixed¶
- The directory browser no longer opens empty when the field holds a file path. The Database Location field stores a file (
vault.db), which the browse API rejects, so opening the browser from it silently failed and showed a blank dialog with no way to navigate. The browser now falls back to the containing directory — and to the top-level listing if that also fails — so it always opens somewhere usable.
Changed¶
- Browsing for a Database Location or Temporary Work Area no longer applies the change on its own. Confirming a folder in the directory browser saved and applied it immediately, while typing the same path required clicking Apply — so the two ways of setting the field behaved differently, and picking a folder could move the database before you had a chance to review it. Browsing now only fills the field; Apply commits it in both cases.
- Changing the database location now moves the database instead of copying it. Pointing Vault at a custom location (or reverting to the default) previously wrote a fresh copy at the new path but left the old one behind, so a stale
vault.db— plus its rotated copies — stayed on the previous drive indefinitely. Because startup picks the freshest valid database across all known locations, that leftover was not merely untidy: if the new drive was ever slow to mount (a ZFS pool importing after the cache pool, for example), the old copy could be selected and silently undo the move. The previous location is now retired once the new one is confirmed written, so nothing is left to go stale. Only Vault's own files are removed — a custom location that is a share or pool root keeps everything else in it untouched, and the old copy is always left in place if the new one did not write successfully.
[v2026.07.08] - 2026-07-21¶
Fixed¶
- The web UI no longer keeps running the old version after a plugin upgrade.
index.htmlwas served with no cache headers at all, so browsers applied their own heuristic caching and could keep loading the previous JavaScript bundle against the freshly-upgraded API until the user manually hard-refreshed.index.htmlis now explicitly never cached, while the content-hashed assets under/assets/are cached permanently (they can never change identity), so upgrades take effect on the next normal page load. - Deleting a storage destination that jobs still use no longer leaves those jobs silently broken. Confirming the deletion previously removed the destination but left every dependent job enabled and pointing at an id that no longer existed — the Jobs page showed the destination as "Unknown" and the scheduler kept starting runs that could never write anywhere. Dependent jobs are now disabled and their destination cleared, and the response reports how many were affected so the UI can tell you what to re-point.
- Creating or editing a job with a storage destination that doesn't exist now explains the problem instead of failing with a generic "internal server error" (HTTP 500). The invalid reference is reported as a normal validation error naming the offending id.
- Job settings are validated before they are saved. Unrecognised values for backup type, compression, encryption, container/VM mode, verification mode and notification trigger — plus negative retention counts, out-of-range retry overrides and excessively long job names — were previously accepted and stored, only surfacing later as a confusing failure mid-backup. They are now rejected with a message naming the field and its allowed values. (Upload concurrency continues to be clamped rather than rejected, as before.)
- "Test Connection" against an unreachable host now reports the failure instead of returning nothing. When a remote host silently dropped packets, the connectivity probe and the server's response deadline expired at the same instant, so the error could never be sent and the browser saw an empty reply after a 15-second wait. Probes now finish early enough for the error to be delivered — a blackholed replication target reports a clear timeout error in about 10 seconds.
- Dashboard protection counts no longer double-count items that belong to more than one job. A VM or container covered by two jobs was counted once per job, inflating the totals; and when only one of those jobs had produced a restore point the same item was reported as both "protected" and "pending" at once. Items are now counted once, and an item backed up by any job counts as protected.
- Size anomaly messages no longer contradict their own numbers. A run marginally smaller than usual could be described as "This backup grew to 2.7 GB, about <1× its usual 2.8 GB". The wording now follows the actual direction of the change.
- Restore item-type filters are labelled properly — the ZFS filter read "zfss" and virtual machines read "vms"; they now read "ZFS Datasets" and "VMs".
- Jobs, Restore, and History stay responsive with production-scale data. The pages now hydrate job items, anomaly baselines, and run history through bounded bulk queries instead of issuing hundreds of browser requests; bulk queries are cancellation-aware and indexed for deterministic newest-first history.
- Replica mode no longer requests daemon-only discovery APIs or exposes daemon-only pages through direct links. Jobs, Restore, Recovery, and Recover Vault now redirect to Dashboard on a replica, matching the hidden navigation and read-only API contract.
- Custom folder jobs can no longer bypass the server's allowed-root policy by typing or crafting a path. Create and update validate
settings.pathand legacy path fields against the same/mnt,/boot, and discovered-ZFS boundary used by the browser, while retaining overlap protection against backing up into the destination itself. - Recovery readiness now counts configured items consistently when discovery is unavailable. Duplicate items are counted once, custom folders remain tracked, Flash Drive follows its own setting, and the page no longer shows a false empty state beside a non-zero protected count.
-
Historical anomaly messages with the old “grew …
<1×” contradiction now render correctly in both Anomalies and Activity Log without rewriting stored audit history. -
Very large VM backups to Hetzner Storage Box (WebDAV) no longer fail on the final metadata write (closes #242). Hetzner answers a WebDAV "create directory" request for a directory that already exists with
403instead of the standard405, which made the very last, tiny file of a multi-hour VM upload fail and discard the entire finished backup. Vault now recognises this reply (and any other non-standard status) by verifying that the directory actually exists, and treats it as success. Long VM copies also log a progress milestone every 5 minutes (percent, bytes, elapsed time), so a failure hours into a large backup is finally diagnosable from the diagnostics bundle. - Folder backups of cache-only shares no longer produce empty archives. On Unraid a cache-only share appears under
/mnt/user/as a symlink to/mnt/cache/, and the backup walk did not follow it — it saw a symlink rather than a directory, never descended, and wrote an archive containing nothing. Both the classic tar and dedup paths now resolve the source symlink before walking, so the share's real contents are captured. The recorded restore location still uses the path you configured (e.g./mnt/user/myshare), not the internal cache target, so restores land where you expect. - Configuration — including the Temporary Work Area path — no longer reverts to an old state after a server reboot (closes #241). Two root causes fixed: (1) on boot Vault now restores the freshest valid database copy by comparing modification times across all persistence locations — including the USB-direct database written when a boot ran before the cache pool was mounted, which the old fixed-order chain never even considered, so newer settings were silently overwritten by a stale cache snapshot; (2) the plugin now installs Unraid event hooks:
stopping_svcsstops the daemon gracefully on reboot/array-stop so the final database flush gets the chance to complete before the OS kills processes, andstartedstarts the daemon after the array is up if the boot-time start raced the pool mount.
[v2026.07.07] - 2026-07-19¶
Security¶
- Storage adapters now contain symlinks to the destination root. A symlink planted under a local destination can no longer redirect reads, listings, or deletes outside the configured base path (resolved-to-resolved comparison, so destinations that legitimately live behind symlinks like
/mnt/userfuse paths keep working). SFTP destinations get the same server-side check (viarealpath) on the browse/download surface. S3 has no symlinks; SMB/NFS client libraries offer no resolution primitive and follow their server's own symlink policy.
Fixed¶
- Very large VM backups (100 GB+) no longer fail or destabilise the daemon (closes #239). The VM engine's fixed 2-hour wall-clock limit is replaced by a progress-resetting 30-minute idle timeout, so a huge disk that is still copying can run for as long as it needs — only a genuinely stalled job is aborted, and Vault now attempts (with retries and verification) to abort the in-flight libvirt job on stall or cancellation so the hypervisor stops writing into the staging area. Full VM backups check staging free space up front and fail fast with an actionable message instead of dying mid-copy, and background worker panics (uploads, downloads, health checks, async cleanups) are contained as failed runs instead of crashing the whole daemon.
-
Restoring an incremental/differential folder backup no longer resurrects deleted or excluded files (closes #231). Folder backups now record an authoritative point-in-time file listing alongside each restore point; after replaying the chain, the restore prunes files that earlier chain steps wrote but that no longer existed (or were excluded) at the selected point. Dedup folder restore points restore directly from their complete manifest — no chain replay at all, which also makes them faster. The restore wizard's file picker reflects the same rules, so it only offers files a restore will actually produce. Backups made before this version have no listing and keep the old overlay behavior.
-
Running and queued backups can now actually be cancelled from the UI (closes #235, closes #238). The documented Cancel flow never had a button: the Jobs page now shows a Cancel control on the running/queued job's row, and the Dashboard's "Backup in progress" tile has a Cancel button with live "Cancelling…" state. Cancelling a job that is queued behind another run (e.g. Run Now on a scheduled job while a backup is active) removes it from the queue before it starts — previously such runs could not be stopped at all. Deleting a running job now cancels its run first instead of leaving the backup goroutine running against deleted records.
Added¶
-
Adaptive backups — jobs can wait for their workloads to go idle (closes #240). A new per-job "Adaptive Backup" toggle checks each container's CPU/network activity (Docker stats), each VM's CPU activity (libvirt), and each folder's recent writes before a scheduled run starts. Busy workloads (Plex mid-stream, a download still writing) postpone the backup with a clear reason in History (new
postponedstatus), re-checking every few minutes until idle — or until a configurable max-postpone window elapses, when the backup runs anyway so busy systems still get protected. Manual Run Now always runs immediately. Idle thresholds and windows are configurable in the global settings; probe failures never block a backup (fail-open). -
Local destinations can now be throttled too (#237 follow-up). The per-destination bandwidth cap — previously hidden for local storage — now appears as a "Disk write speed limit" on local destinations, capping how fast backups write to disk so apps streaming from the same array (Plex, Jellyfin) keep their I/O headroom while a backup runs. The cap is write-only: restores and verification always read at full speed. Note the unit is megabits (8 Mbps = 1 MB/s). The cap applies to the copy-to-destination phase; keep staging on a different disk (cache, the default) for full effect.
- Adaptive upload throttling — Vault now yields bandwidth to Plex, Jellyfin and friends (closes #237). An opt-in "Upload Throttling" section in Settings lets you enter your link's upload capacity; Vault then continuously measures non-Vault traffic on the network interface and slows its own uploads to network destinations so streaming keeps its bandwidth, speeding back up when the link goes quiet. A configurable minimum rate keeps backups progressing, per-destination bandwidth caps still apply (stricter limit wins), and changes take effect within seconds without a restart.
- Browse the files on a storage destination like an FTP client (closes #236). Each destination card on the Storage page now has a Browse button that opens a read-only file browser — drill into folders with breadcrumb navigation and see file sizes and modification times for whatever Vault sees on the destination (local, SFTP, SMB, NFS, WebDAV, S3). Available in replica mode too since it never writes anything.
Changed¶
- The Replication and Storage Logging toggles in Settings now apply immediately — flipping the switch saves right away (rolling back with an error toast if the save fails), so the separate "Save Replication Settings" and "Save" buttons are gone.
- The Full Form "sensible defaults" note in the job editor is now a prominent info callout instead of small dim text, so it's clear why Advanced Options are not shown in Full Form mode (closes #232).
- Documentation is now linked directly from the sidebar — a new "Documentation" entry in the System section opens the Vault manual in a new tab (closes #233).
- Backup type docs and job editor now explain first-run behavior: selecting Incremental or Differential on a new job automatically performs a Full backup on the first run, so no manual initial Full is needed. Clarified in the manual, the API reference, the
backup_type_chainfield description, and the job editor's helper text (closes #234).
[v2026.07.06] - 2026-07-18¶
Fixed¶
- Restoring an incremental or differential backup no longer sends duplicate "Restore completed" notifications. The runner notified once per chain step (base full + each increment), so a single folder restore could produce several identical Unraid notifications — some arriving while the restore was still running. One notification is now sent per restored item, after the whole chain has been replayed.
- The restore wizard's file picker now works on incremental and differential restore points. Browsing a restore point only listed the files captured by that specific increment — often nothing, showing an empty "No files match" picker even though a restore would recover the full folder. The picker now merges the file lists of the entire backup chain (base full plus increments, newest version of each file winning), matching what a restore actually produces.
[v2026.07.05] - 2026-07-18¶
Fixed¶
- Folder backups now honor the
exclude_pathssetting (#230). The runner never passed a folder item's configured exclusions to the backup engine, so exclusion patterns (e.g.*.logor alogsdirectory) were silently ignored and excluded files still ended up in folder backups. Exclusions now apply to both classic tar and dedup/chunked folder backups, matching the container behavior. Note: exclusions added to an existing incremental/differential chain take full effect from the next full backup (#231). Thanks to @0x1917 for the fix and regression tests!
[v2026.07.04] - 2026-07-16¶
Fixed¶
- Bind address and port changes now actually restart the daemon (Settings → Vault → Apply). The plugin package shipped
apply.shwithout execute permission, so Unraid's settings-save hook failed with a silent "Permission denied" and the daemon kept running on its previous address. Selecting a NIC address (e.g.192.168.20.21) then made the web UI report Vault daemon unavailable while Start insisted it was already running, because the daemon was still bound to127.0.0.1. The package now shipsapply.shexecutable and the installer re-applies the permission on every install/upgrade, healing existing installs.
[v2026.07.03] - 2026-07-14¶
Added¶
- Recover Vault wizard — guided disaster recovery after a server rebuild (closes #220). A fresh install now offers a clear choice — Set up Vault or Recover from a backup — and the new five-step wizard walks you through reconnecting your backup storage, entering your backup password, restoring the Vault database (jobs, storage, history, settings), and fixing folder paths that changed on the rebuilt server. Also reachable any time from Recovery → Recover Vault.
- Database restore now works with encrypted backups.
POST /api/v1/storage/{id}/restore-dbrestores the encryptedvault.db.latest.agesnapshots Vault writes by default (previously only a plaintextvault.dbcould be restored), verifies your backup password before touching anything (verify_only), re-seals the password with the new server's key so scheduled DB backups stay encrypted after recovery, and reloads the database in-process — no daemon restart needed after a restore. GET /api/v1/storage/{id}/db-backupslists the database snapshots available on a destination (newest first, with the latest pointer flagged).- Path check after recovery:
GET /api/v1/recovery/path-auditflags folder and local-storage paths from the restored config that don't exist on this server, andPOST /api/v1/recovery/path-remapupdates them in place — the exact "my array and shares are different now" pain from #220. - Disaster Recovery guide in the docs — including why hand-copying
vault.dbto the flash drive doesn't work and what to do instead.
Fixed¶
- Vault's own database now reliably runs in WAL mode with a 30-second lock timeout. The SQLite driver was silently ignoring the connection options that were meant to set these, so freshly created databases ran in rollback-journal mode with no busy timeout — slower under concurrent access and more prone to "database is locked" errors. All connection settings are now applied in a form the driver understands, and Vault logs a warning if WAL is unavailable on the filesystem.
Security¶
- Snapshot rotation and database-backup temp-file cleanup now refuse to delete any path that isn't absolute and traversal-free — hardening flagged by CodeQL; no user-visible behavior change.
Changed¶
- Dashboard is now uncluttered on a fresh install. Until you've configured storage and your first backup job, the dashboard shows just the Getting Started guide plus the four at-a-glance KPI tiles (Health, Protected, Next Run, Last Backup). The heavier panels — Backup Jobs, Protection status, Recent Activity, 3-2-1 rule, and the storage/analytics tiles — stay hidden until there's real data to fill them, so they no longer render empty. They appear automatically once your first job exists.
- Documentation screenshots refreshed to match the current Vault dashboard, storage, job wizard, recovery, responsive, and theme-aware UI.
- License changed from MIT to AGPL-3.0. Vault is now distributed under the GNU Affero General Public License v3.0 to keep derivative and network-served modifications open source.
[v2026.07.02] - 2026-07-09¶
Added¶
- VM backup jobs now detect raw vs qcow2 disks and only offer supported backup types (closes #163). Vault reads each VM's disk format at discovery time and exposes it (
disk_format,supports_incremental) onGET /api/v1/vms. Because libvirt checkpoint-based incremental/differential backups require qcow2 disks, the job wizard now hides those options — and falls back to Full — when a selected VM uses raw or mixed disks, with an inline note naming the affected VM. This surfaces up front the constraint the engine previously enforced silently by downgrading raw-disk VMs to full backups. - Pre/post-backup scripts now receive job context as environment variables (part of #187). The job editor has long promised
VAULT_JOB_NAMEandVAULT_STATUSto hook scripts, but they were never actually set. Vault now exportsVAULT_JOB_NAME,VAULT_STATUS(startingfor pre-scripts, the run's final status for post-scripts),VAULT_JOB_ID, andVAULT_RUN_IDinto every pre/post-backup script's environment — enabling, for example, an Immichpg_dumppre-script that targets the right job. - Container exclusion presets can now carry advisory notes and warnings (part of #187).
GET /api/v1/presets/exclusionsreturns optionalnotes/warningsfor apps that need a caveat, and the job wizard shows them inline beneath the container's exclusions. - Docker named volumes are now backed up, not silently skipped. Vault previously only backed up bind mounts, so containers that store their data in named
docker volumes (many compose stacks — the single most-reported gap for the old appdata.backup plugin) were archived empty. Vault now backs up named volume mounts too — a volume's data lives at a real host path (/var/lib/docker/volumes/<name>/_data), handled exactly like a bind mount — across the classic, dedup and restore paths, and reattaches them on restore via the container's bind spec. The job wizard's mount editor lists them tagged "named volume" so you can include or exclude each one. Anonymous volumes (a random 64-hex id) are deliberately skipped because they can't be reliably reattached on restore; tmpfs/npipe and other non-data mounts are skipped as before. - Compression level is now configurable per job (fastest / default / better / best). The job wizard exposes a level next to the compression algorithm, mapped to the zstd/gzip encoder levels, so you can trade backup time for archive size. The default level is unchanged, so existing jobs and their archives are byte-for-byte identical.
- Jobs that would back up into their own source are now rejected up front. Saving a job whose local destination path sits inside (or contains) one of its folder/flash source trees now returns a clear error instead of silently archiving each run into the next run's source — the old plugin's "backing itself up" footgun. Remote destinations are unaffected.
- Sparse files that could stall a backup are now flagged. Vault logs a warning when a large file's logical size vastly exceeds its on-disk size (e.g. a Meilisearch/LMDB store reporting terabytes but occupying kilobytes), so a slow backup reading the full logical extent is no longer a silent mystery — the exact trap behind the old plugin's multi-hour stalls.
- Containers that route their network through another container are flagged. When a container uses
network_mode: container:<other>(a Gluetun/VPN-sidecar setup), Vault now logs a note reminding you to include the provider in the same job and order it to start first, so the dependent doesn't lose its network when restarted after backup. - The Command Palette (⌘K) now finds backup items and restore points, not just pages (closes #210). Typing a container, VM, or folder name surfaces it under an Items group and jumps straight to its restore view in one action; each job's most recent restore point is listed under Restore Points. Results are grouped (Pages / Items / Restore Points / Jobs / Storage) and loaded once when the palette opens, so filtering stays instant.
- Storage destinations can now be tested before saving (part of #206). The add/edit modal has a prominent Test connection button that validates the current form — including S3 credentials and endpoints — against the backend before you commit the destination, with clear pending / success / failure states. Backed by a new
POST /api/v1/storage/testendpoint that builds an ephemeral adapter from the posted config. - Two more Dashboard tiles — Retention & cleanup and Replication status (Dashboard polish). Retention & cleanup shows how many restore points a job's long-term-retention policy would prune next, out of the total, plus when the next prune runs; Replication status rolls up offsite sync state across replication sources (synced count and most recent last-synced time, red when any source failed). Both draw from data already on the page, appear in the Customize catalog, and hide automatically when no job has a retention policy / no replication source is configured.
- Redesigned, customizable Dashboard (Dashboard "5A" + E13). The Dashboard now leads with a status hero — Health, Protected (X/Y + bar), Next run countdown, and Last backup result — so "am I protected right now?" is answered above the fold, followed by a live Backup in progress / Recent activity / Backup jobs / Protection status grid. Every tile is drawn from data already on the page (no new endpoints). Info tiles are click-through — Next run → Jobs, Last backup / Health / Success rate → History, Storage → Storage, and so on. A Customize mode lets you add, remove, and reorder tiles (drag on desktop, ↑/↓ buttons for keyboard and touch) from a catalog that also includes Storage (combined & per-target), Recovery readiness, Needs attention, Success rate, Anomalies, Quick actions, Backup size trend, Backup calendar heatmap, Dedup & compression savings, Storage forecast ("full in ~N days"), and Largest backups — each drawn from data already available (history trend, dedup stats, capacity trajectory) and loaded lazily only when the tile is added; Tiles are resizable in Customize mode (narrower/wider steps across the 12-col grid), size to their content, and compact vertically — each tile packs upward into the space a shorter neighbour would otherwise leave beside a taller one, so the grid stays tidy with no holes between tiles (a fixed-grid-with-compaction model, as used by Grafana/Datadog/gridstack, rather than Pinterest-style masonry which reorders unpredictably when the layout or screen size changes). The layout and per-tile widths persist per-browser (
vault:dash:layout:v1) and Reset restores the default. Every tile now follows one compact, data-dense layout — an icon-chip header with an uppercase label, a large tabular-figure value, and a micro-visual (progress/usage bar, sparkline, dedup ratio, or the contribution-style calendar heatmap) — for a consistent, professional ops-dashboard look in light, dark, and retro themes. On phones the small KPI tiles sit two-up (single column only below 360px). The 12-column grid reflows to 2-up tiles and full-width panels on tablets and phones (single column on very small screens), and works in light, dark, and all three retro themes. The Create Job wizard now offers a Switch to full form toggle that renders every primary field on one scrollable page for users who create jobs often; advanced settings keep their defaults and stay available in the step-by-step view.
Fixed¶
- Dashboard polish — accessibility and correctness. Added an app-wide
prefers-reduced-motionguard so motion-sensitive users no longer get the live-progress pulse, progress-bar shimmer, run spinners, or the long health-ring/bar transitions (only a handful of named animations were previously covered). Compact KPI tiles (Health, Protected, Next run, Success rate, Needs attention, and the like) can no longer be stretched to full width in Customize mode — a per-tile width cap keeps a single figure from becoming an awkward full-width band, and any width persisted beyond the cap is clamped on load; wide panels (Jobs, Activity, Calendar, Size trend, Largest) still reach full width. On phones the narrower/wider resize arrows are hidden in Customize mode — the grid is forced to 1–2 columns there, so tapping them changed nothing — while reorder (↑/↓) and remove (×) still work. Essential KPI captions moved from the fainttext-dimtotext-mutedfor WCAG-AA contrast in light mode, and the Health tile gained a "View backup history" tooltip clarifying where it navigates. - Replica (read-only) instances no longer show write actions they can't perform. In replica mode the Dashboard (run-all, quick actions, per-item restore, 3-2-1 dismiss), Storage (add/edit/delete/import/DB-backup/cleanup/breaker), Replication (add/edit/delete/sync), Anomalies (ack/dismiss), History (purge), and the destructive/secret Settings controls (API key, encryption, snapshot path) are now hidden behind a "Read-only replica" notice, instead of surfacing buttons that only fail against the server's read-only guard.
- Duplicating a backup job no longer opens an unsaveable form. Duplicate now carries the original job's backup type(s) across (it previously copied the items but not the type selection), so the wizard doesn't strand you on an invalid first step. Inline job rename also no longer fires twice when you press Enter and then blur.
- Recovery readiness copy now matches the ring, and the Restore banner tells the truth about where data lands. The readiness summary follows the ring's colour bands (red under 50%, amber 50–99%, green only at 100%), an empty setup reads "No items to protect yet" instead of a false 100%, and the unprotected list now includes folders/flash so it matches the count. The restore overwrite banner shows a concrete path only when it's actually known (custom destination, or a folder/flash item) and says "its original location" for containers/VMs rather than a hard-coded
/mnt/user/appdata/...that could be wrong. - Assorted UI robustness fixes.
partial/pendingbackup runs now render amber consistently (Dashboard, History) instead of red/grey; the storage connection test can't show a stale "Connection OK" if you edit the form mid-test; the Settings "Database" jump link is hidden when that section isn't shown; the Dashboard protection bar turns green only when every item is truly protected; API requests now time out after 15s and report expired auth clearly; and Storage/History surface a load error instead of a falsely-empty "all clear" when their initial fetch fails. - Express-mode job creation no longer submits invalid forms via Enter key. The Jobs page form submit handler now applies the same
canSaveExpressgate as the disabled Save buttons, so pressing Enter in an input cannot bypass required item/storage/name validation. - The Dashboard "Next run" now shows the real countdown again. The
/jobs/next-runs(and/jobs/{id}/next-run) endpoints returned empty because the API router — and its job handler — is built inNewServer, before the daemon wires the scheduler's next-run resolver, so the handler captured a nil resolver and every job reported "not scheduled". The resolver is now propagated to the already-registered handler when it's set, so scheduled jobs report their next run correctly. - Backups no longer fail when the Recycle Bin plugin is installed (closes #204). The Unraid Recycle Bin plugin creates a
/mnt/RecycleBinview directory (symlinks/subfolders, not a real pool) that it rebuilds periodically. Vault's staging-pool discovery treated it as a normal pool and staged backups there, so a docker or flash backup could fail mid-run withopen /mnt/RecycleBin/.vault-stage/…: no such file or directorywhen the plugin regenerated the view./mnt/RecycleBin(and any dot-prefixed/mntentry such as a stray.Recycle.Bin) is now excluded from staging-pool candidates. - Flash and folder backups now honour path exclusions (closes #204). The classic tar backup path used for flash/folder items on non-dedup destinations ignored
exclude_pathsentirely, and the job wizard offered no way to set them. Folder items (including the Flash Drive preset) now have an "Exclude paths" editor in the wizard's advanced step, and the engine applies those exclusions on the classic path just as it already did for deduplicated backups — so you can, for example, exclude a share's.Recycle.Binfolder from a flash backup. - The Recycle Bin plugin's trash folder is auto-excluded from the Flash Drive backup when detected (closes #204). Vault now detects the installed Recycle Bin plugin (via its
/boot/config/plugins/recycle.bin.plgmarker) and, when present, pre-fills.Recycle.Binas a recommended exclusion for the Flash Drive backup in the job wizard — so deleted files held in the trash aren't backed up. The pre-filled exclusion is shown in the editor and can be changed or removed.
Changed¶
- Rate limiting on the encryption-verify and API-key endpoints now uses an explicit client-IP key. Migrated off
httprate'sLimitByIP, which a recent dependency bump deprecated (tripping the lint/build gate), toLimitBywith a documented key function. Behaviour is unchanged — requests are still bucketed by the connecting TCP peer address — the trust model is just stated explicitly now. - Anomaly messages are now plain-English sentences (closes #192). Every detector now describes what happened versus what was expected in friendly language — e.g. "This backup took 4m 23s, about 1.2× its usual 3m 40s" instead of "backup duration anomaly: 4m 23s (1.2x median)" — across the dashboard, the anomalies page, notifications, the API, and MCP. Notifications also replace the raw JSON blob (
{"z_score":11.06,...}) with a short context line such as "Based on the last 10 samples", while the precise statistics remain available in the structureddetailsfield for programmatic consumers. - Tdarr container backups now drop stray log files and are covered by tests (closes #188). The Tdarr exclusion preset already skipped the transcode cache (
/temp) and log directory (/app/logs); it now also excludes stray*.logfiles, while deliberately keeping/app/server(the DB2 database, plugins and samples) and/app/configsso Tdarr settings and statistics restore cleanly. Both the combinedtdarrimage and thetdarr_nodeimage are recognised, now with regression tests to keep detection working. - Contrast — new on-light accent token fixes amber-on-white text below WCAG AA 4.5:1 for small links and captions (Dashboard, Jobs, History); dark and retro themes unchanged (closes #205).
- Filter clarity — paired filter groups on History (Status / Kind) and Logs (Category / Level) now carry muted micro-labels so two amber "active" controls no longer read as one (closes #205).
- Recovery readiness ring — ring is now red below 50%, amber mid, and green only at 100%, matching the Dashboard protection bar (closes #205).
- Anomaly "Expected" tooltip explains that marking Expected teaches the baseline (future similar runs won't be flagged), versus Dismiss which only clears it once (closes #205).
- Restore overwrite banner names the concrete target path (e.g.
/mnt/user/appdata/plex, a folder's own path, or the chosen custom destination). Restore deep-links (?type=&name=) from the Dashboard and command palette now preselect the item (closes #205). - Sidebar groups its items under Protect / Recover / System headers for faster wayfinding; replica mode and feature-flag hiding are preserved (closes #209).
- Dashboard Protection Status collapses to a one-line summary at 100% coverage (choice persisted) so Recent Activity and Jobs move up; stays expanded when anything is unprotected (closes #211).
- Storage add flow replaces the type dropdown with an icon card picker and adds S3 provider presets (AWS, Backblaze B2, Cloudflare R2, Wasabi, MinIO, IDrive E2, MEGA) that prefill endpoint/region (closes #206).
- Job wizard consolidated to 4 clear steps — What, Where & when, How, Name & review — with no fields removed and the express "full form" path intact (closes #207).
- Settings › General gains a sticky Jump to bar so everyday toggles are reachable without long scrolling (closes #208).
- Immich container backups are now correct for both image variants (closes #187). The exclusion preset previously only covered the imagegenius fork's
/photoslayout; it now also covers the officialimmich-appimage's/datalayout, excluding only regeneratable thumbnails and re-encoded video while always keeping the originalupload/library/profileassets and Immich's built-inbackups/database dumps. The wizard now also surfaces a warning that Immich's PostgreSQL database runs in a separate container and must be captured via Immich's built-in database backup, a dedicated Postgres backup job, or apg_dumppre-script — see the Backup Jobs guide in the documentation for the recommended setup.
[2026.07.01] - 2026-07-04¶
Added¶
- Documentation link in Settings → Reference (closes #189). A new Documentation card at the top of the Reference tab links directly to the online manual at tinkerologie.github.io/vault, so users can reach the guides and settings reference without leaving the plugin.
- Optional "Backups" quick link in the Unraid top navigation (closes #162). Enable it under Settings → Utilities → Vault → Quick Access to get a one-click Backups entry in the Unraid top bar that opens the Vault web UI embedded in the Unraid interface. Off by default. Unlike the community add-on that inspired it, this uses Unraid's supported plugin menu extension point — no core files are patched and no background watchdog is needed; the embedded view also refreshes its session token automatically when you return to a long-idle tab.
Fixed¶
- Anomaly baselines no longer report a 100% failure rate for every job (closes #181). The baseline refresher counted a run as failed unless its status was
success— a value the runner never writes (successes arecompleted) — so every job's stored failure rate was a meaningless constant 1.0. Failures are now counted the same way the reliability detector counts them (failedstatus or failed items), producing real rates. - Database snapshots are now written atomically (closes #182). The pool snapshot and USB safety-net copy were written directly over the previous good file, so a crash, power loss, or full disk mid-save corrupted the very copy the daemon needs for disaster recovery. Snapshots are now written to a temp file and renamed into place only after completing successfully — and if the primary snapshot is ever unreadable at startup, the daemon now also tries the rotated snapshot copies (newest first) before falling back to the USB copy or a fresh database.
- Post-backup health checks show container names again after a container was recreated (closes #178). In stop-all mode, the health check after restarting containers looked names up by the stored container ID — but when a container had been recreated (update, compose recreate) the runner works with the re-resolved ID, so the check reported the raw 64-character ID instead of the name. Names now resolve through the re-resolved IDs too.
- Filename validation now rejects single backslashes (closes #179). The path-separator guard in restore-name validation checked for a double backslash due to an escaping mistake, letting a single
\through this specific check (harmless on Linux, but the guard didn't do what it said). It now rejects both/and\. - Replication sync results are now honest about failures (closes #177). A sync whose jobs all failed to copy still recorded "success" and announced a completed sync — a false safety signal for an off-site-copy feature. Sync status is now
successonly when every job synced,partialwhen some failed, andfailedwhen nothing synced, with the failure count shown on the Replication page (which previously showed failed syncs as "Pending"). - Replicated restore-point metadata can no longer be silently corrupted (closes #176). The replication origin marker was spliced into restore-point metadata by string concatenation, so a source name containing quotes/backslashes — or metadata like
{ }— produced invalid JSON, silently stripping checksums and dedup manifest references from every replicated restore point. The metadata is now rebuilt through the JSON encoder and is always valid. - Restoring over an existing installation no longer fails on pre-existing symlinks (closes #175). Regular files are overwritten during restore, but symlink and hardlink entries were created without replacing what was already on disk — so restoring a container/folder backup onto a live appdata tree aborted with "file exists" at the first pre-existing link (version symlinks, certificate links, node_modules/.bin). Links are now replaced like files; directories are never silently removed.
- Deduplicated backups no longer fail outright on a single unreadable file (closes #174). Classic backups skip inaccessible files with a log line, but the deduplicated (chunked) path aborted the entire item on the first permission-denied or vanished file — so the same job succeeded on a classic destination and hard-failed on a dedup one. The chunked walk now skips-and-logs identically, reporting the number of skipped paths in the log and progress message.
- Automatic database backups are now pruned instead of accumulating forever (closes #184). After every successful run Vault copies its own database to the destination's
_vault/directory, but old copies were never removed — one server had accumulated 136 copies (477 MB) in six weeks, and on quota-limited remotes this growth eventually crowds out real backups. Vault now keeps the newest 14 timestamped copies per destination (plus thevault.db.latestrecovery pointer, which is always kept) and deletes older ones after each write. - Deleting the last dedup job on a destination no longer destroys the daemon's database backups stored there (closes #183). The dedup chunk repository and the automatic
vault.dbrecovery copies share the_vaultdirectory on every destination; the cleanup that removes an orphaned dedup repo deleted the entire directory — including every database backup. Cleanup now removes only the dedup-owned paths (repo.json,packs/,index/) and leaves the database copies untouched. - Replication syncs no longer leak storage connections (closes #173). Each replication sync opened the local storage destination but never closed it; for SFTP destinations that leaked up to four SSH connections per scheduled sync (and orphaned NFS mounts), accumulating until daemon restart. The adapter is now released when the sync completes.
- Daemon shutdown now waits for in-flight restores (closes #172). A planned restart (plugin update,
rc.vault restart) drained active backups but was blind to restores: shutdown could kill a restore mid-write, leaving half-restored appdata or VM disks. Restores now participate in the same drain protocol as backups — shutdown waits for them, and a draining daemon refuses to start new ones. - Cancelling a VM backup or restore now takes effect immediately (closes #171). VM operations ignored cancellation entirely: pressing Cancel (or the stall watchdog firing) had no effect until the libvirt backup job finished or its 2-hour timeout expired, and the hypervisor kept writing disk data into a staging directory the runner had already torn down. Cancellation is now honoured within seconds across every VM stage — the in-flight libvirt backup job is aborted (
DomainAbortJob), multi-GB disk/NVRAM copies stop at the next 1 MiB chunk, and all shutdown/startup/readiness waits exit early — while VM teardown (restarting a cold-backed-up VM, tearing down the paused backup session) still always completes so a cancelled run never leaves a VM in the wrong power state. - Backups of imported jobs no longer crash when a container's stored ID is missing (closes #170). Jobs imported from existing backups (native manifests without per-item IDs, or appdata.backup imports) store containers with an empty ID; when such a job ran while a container with that name existed, the ID-change log line sliced the empty ID and panicked, failing the run with an opaque "Internal error (panic)" on every attempt. ID truncation is now bounds-checked everywhere.
- SMB capacity probes no longer leak a connection on every check (closes #169). The scheduled capacity probe (and the Storage page's refresh button) opened an SMB session for the space query but never logged it off, leaking one authenticated session plus its TCP connection per probe until the NAS started refusing new SMB connections. The probe now releases the session like every other SMB operation.
- Empty directories left behind by deleted backups are now actually cleaned up (closes #168). Deleting restore points (retention, manual deletion) removed the files but the sweep that prunes the now-empty parent directories never ran: the storage middleware chain didn't pass the directory-removal capability through to the underlying adapter, so the sweep silently did nothing on every destination. Local, SFTP, SMB, and NFS destinations now prune emptied parent directories again; object stores (S3) and WebDAV, which have no safe "remove if empty" primitive, are skipped cleanly.
- ZFS incremental and differential chains now record and use the correct parent snapshot (closes #180). The snapshot created by each ZFS backup was never persisted into the restore point, so chained runs silently fell back to sending against whatever vault snapshot happened to be newest on the dataset — a "differential" was really an incremental against the previous run, and snapshot cleanup then destroyed the last full's snapshot (the chain anchor). The result: differential restore chains could not be received (
zfs recv: most recent snapshot does not match incremental source). Each backup now records its snapshot per item in the restore point (zfs_snapshots), the next chain run resolves its-iparent from there (falling back to a full backup with a clear warning when the parent is missing or was deleted — including for restore points created before this fix), and cleanup preserves the chain-anchor snapshot alongside the newest one. - Automatic retries now actually fire for pre-flight failures and stall-cancelled runs (closes #167). When a run was skipped because its destination failed the pre-flight check (DNS blip, unmounted share), or cancelled by the no-progress stall watchdog, Vault scheduled a retry — but the retry watcher only ever dispatched retries for runs whose status was
failed, so those scheduled retries sat in the database forever and never ran. The watcher now claims due retries forfailed,skipped, andcancelledruns alike, matching the outcomes retries are scheduled for. - Truncated archives can no longer be reported as successful backups (closes #166). When finalising a container-volume, folder, or plugin archive, errors from the tar footer flush, the gzip/zstd trailer, and the final write to disk were silently discarded — so a disk that filled up during the last flush, or a file that shrank while being archived, produced a corrupt/truncated archive that the run reported (and even checksum-verified) as success, discovered only at restore time. All three finalisation steps now fail the backup with a clear error (
closing tar writer: …,finalising compression: …,closing archive file: …). - Deduplication garbage collection can no longer run while a backup is writing to the same destination (closes #165). Starting a GC (Storage → Run GC, or the automatic reclaim after deleting a dedup job) while a dedup backup was in flight could delete the chunk packs that backup had just written — its restore point only becomes visible to GC at the very end of the run — silently corrupting the new backup so it would fail at restore time. GC now waits for the active backup/restore to finish before it scans (and backups queue behind a running GC), refuses to start during daemon shutdown, and shutdown in turn waits for an in-progress GC to complete.
- S3 backup verification no longer aborts (and deletes the upload) on large archives (closes #164). S3
Read/ReadRangebound the streamed response body to the same 5-minute deadline used for quick metadata calls, so verifying any archive that takes longer than 5 minutes to stream back failed withcontext deadline exceeded— and the runner then removed the files it had just successfully uploaded, leaving the run failed and the destination empty. Streamed S3 reads now carry no wall-clock deadline (matching SFTP/WebDAV/local): closing the reader releases the connection, waiting for a dead endpoint's response headers is still bounded (5 minutes), and genuinely hung transfers are cancelled by the stall watchdog. Deep verify and restores from S3 destinations benefit the same way.
[2026.07.00] - 2026-07-02¶
Fixed¶
- VM backups now honour the job's compression setting. Container, folder, and plugin backups compress their archives in the engine, but VM backups stage raw artifacts (disk images,
domain.xml,vm_meta.json, NVRAM) and the upload layer never applied a codec — so jobs configured for gzip or Zstandard still landed full-size, uncompressedqcow2/imgfiles on the destination. VM uploads are now compressed in transit with the configured codec (stored as.gz/.zst, compressed before encryption) and transparently decompressed on restore; existing uncompressed backups keep restoring unchanged. A 10 GiB Fedora test disk dropped from 2.7 GB to 1.1 GB on the destination with zstd. - VM backups no longer fail with
backup job ended unexpectedly: DomainJobNone(closes #160). When a libvirt push-backup job finished, Vault asked libvirt for the completed-job record to decide success or failure. On hosts where that record is unavailable at poll time (libvirt reports "no completed job" as a normal reply, not an error — e.g. another tool consumed the record or libvirtd reloaded), Vault misread the reply as a failure even though every byte had been copied, so backups that ran to completion (44+ minutes of real work) were reported as failed on every attempt. Vault now treats a missing completion record as "no verdict" and confirms success from the backup artifacts on disk (logging that it did so); a job that vanishes without producing its files still fails, now with an error naming the missing files instead of looping for 2 hours. restoring domain state: Failed to terminate process … with SIGKILL: Device or resource busyafter VM backups (#160). After pushing tens of GB through the page cache (typical on fuse-backed Unraid shares), the temporary paused backup session's qemu process can sit in uninterruptible IO, so libvirt's single kill attempt timed out, failed the whole backup, and could leave a cold-backed-up VM powered off. Teardown of the backup session (and the forced stop before a cold backup) now retries the destroy for up to 5 minutes until the flush completes, then restarts the VM as before.- qcow2 disks in
.imgfiles are now backed up and restored in their real format (#160). Disk format was inferred from the file extension, but Unraid names most vdisksvdisk1.imgregardless of format. A qcow2-formatted.img(like the Windows VM in the report) was written out as a raw image and restored as raw while the domain XML still said qcow2 — an unbootable restore. The format now comes from the domain XML's<driver type=…>(extension only as fallback), which also makes qcow2-in-.imgVMs eligible for incremental backups for the first time. - VM restore-chain assembly no longer assumes every chain layer ends in
.qcow2. Restoring an incremental chain resolves each step's disk files from thevm_meta.jsonthe backup recorded (with the old filename scan as fallback for pre-existing backups), so chains whose full backup is a.imgfile flatten correctly; a chain whose disks cannot be resolved now fails loudly instead of silently restoring only the newest increment.qemu-imgcalls also honour restore cancellation, and layer/metadata filenames are validated before use. - A VM whose disks are all skipped (e.g. block-passthrough only) now fails its backup with a clear error instead of "succeeding" with no disk data. The per-VM disk inventory (eligible disks, formats, and skipped disks with the reason) plus the exact backup job submitted to libvirt are now logged, so future reports like #160 carry the root cause in
vault.log. - Restore wizard no longer shows
no tar index sidecar found for this itemfor VMs. The per-file restore picker only applies to tar-based backups (containers, folders, plugins); for VM and ZFS items the wizard showed a red sidecar-lookup error that looked like a broken restore point. Those items now show a plain "Restored in full" row, and the disabled Start Restore button explains that the pre-flight checks must be run first.
[2026.06.07] - 2026-06-25¶
Changed¶
- Modals no longer close when you click the background (closes #148). Clicking the dimmed backdrop behind a dialog – or releasing a text selection whose drag ended outside the dialog – used to dismiss the modal and discard any in-progress work, such as a half-filled backup job. Backdrop click-to-close has been removed from every modal: the job and storage wizards, confirmation dialogs, the command palette, and the path and script browsers. Modals are still dismissed the deliberate ways – the close (×) button, Cancel, or the Escape key – all unchanged.
Fixed¶
- Creating a backup job now validates its input instead of saving a broken job or returning a 500.
POST/PUT /api/v1/jobspreviously performed no validation: a request with no name (or a whitespace-only name) was saved as an unusable job, and a request with an invalid cron expression was saved as a job that silently never ran. The job create/update endpoints now reject an empty name with400 name is requiredand an unparsable schedule with400 invalid schedule: …(an empty schedule is still allowed — that means a manual-only job), matching the validation the storage and replication endpoints already had. Last-day-of-month schedules (0 2 L * *) and@-descriptors remain valid. - Duplicate names now return a clear 409 instead of a generic 500. Creating or renaming a job, storage destination, or replication source to a name that already exists tripped the underlying
UNIQUEconstraint and surfaced as500 internal server error. These now return409 Conflictwith an actionable message (e.g. "a job with this name already exists") so the UI and API clients can show the real reason. - Container appdata on Unassigned Devices is no longer auto-excluded from backups (closes #152). Bind mounts under
/mnt/disks/(the Unassigned Devices mount point) were wrongly flagged as a "direct disk volume" and shown greyed-out in the job wizard's Container Mounts & Exclusions step, so appdata kept on an Unassigned Devices SSD (e.g. a container's/configat/mnt/disks/<device>/appdata/<app>) could not be included in a backup. The direct-disk rule now matches only real array disks (/mnt/disk1,/mnt/disk2, …) and no longer catches the plural/mnt/disks/, so those mounts are backed up by default and are tickable like any other path.
[2026.06.06] - 2026-06-22¶
Added¶
- Richer Discord notifications: bot identity and role pings (#146). The Settings → Notifications → Discord card now lets you override the Bot Username and Bot Avatar URL the webhook posts under, and ping a Mention Role ID on alerts with a Mention On control (Never / Failures only / All backups). Pings are deliberately safe — only the role you specify is mentioned (
allowed_mentionsis locked down, so a stray mention can never@everyone), the role ID is validated as a numeric snowflake before use, and the Test button now reflects your configured bot name/avatar. By default nothing pings (Mention On = Never), so existing setups are unchanged.
Fixed¶
- You can now choose to be notified on successful backups, not just failures (closes #146). The per-job notification preference (
notify_on) was already honoured by the backend and submitted by the job form, but the job wizard never rendered a control to change it, so it was stuck at itsfailure-only default and there was no way to opt into success notifications. The job wizard's Step 5 → Advanced now has a dedicated Notifications section (split out from Scripts for clarity) with a Notify On dropdown — All backups (success & failure) / Failures only / Never — and a tooltip noting that the global notifications toggle in Settings must be on and that Discord is configured separately. No backend, database, or API changes were required.
[2026.06.05] - 2026-06-20¶
Added¶
- Configurable history retention. A new Settings → General → History Retention control (30 days / 90 days / 6 months / 1 year / 2 years / Keep everything; default 1 year) caps how long backup/restore run history is kept, so the database doesn't grow without bound. The purge (which runs during the daemon's maintenance pass) is deliberately safe: it only removes run-history rows that are both older than the chosen period and no longer back a restore point – recoverable backups are governed solely by each job's own backup retention and are never deleted by this setting.
- Selectable backup-size trend window. The History page's "Backup Size Trend" chart now has a period selector – 7d / 30d / 90d / 6m / 1y (default 30d) – instead of a fixed last-30-runs view. Long ranges are aggregated server-side into readable buckets (per run for 7d, per day for 30d/90d, per week for 6m/1y) and the bars are stacked by backup type (containers / VMs / folders / flash). Backed by a new
GET /api/v1/history/trendendpoint. - Capacity trend and runway forecast on storage destinations. Each destination's capacity block now shows a sparkline of usage over the last 90 days (the samples Vault already collects) plus a plain-language runway estimate – "~N days until full at current growth", "Usage stable or shrinking", or "Not enough history yet" – computed from a linear fit of recent samples. The trend and runway colour-shift to amber/red as the destination nears full. Quota-less providers (S3, generic WebDAV) show the usage trend without a percentage or runway.
- Dedup savings bar. Deduplicated destinations now show a visual savings bar beside the existing ratio text: how much of the logical data is physically stored versus how much deduplication avoided writing (e.g. "Stored 29% · Saved 70.6 GB").
- Search, filter, and sort your backup jobs. The Jobs page now has a toolbar above the list: a search box (matches name and description), a status filter (All / Enabled / Disabled), a storage filter (any destination), and a sort control (Name A–Z, Next run, Recently created). A "Showing X of Y" counter and a Clear filters link appear when a filter is active, and the existing Select all plus bulk actions (enable / disable / run / delete) now operate on the filtered set – so you can, for example, filter to Disabled and bulk-enable just those. All client-side over the already-loaded list, so there is no extra loading. Useful once you have more than a handful of jobs.
- Pre-flight checks before a restore. The restore wizard's final step now runs a quick go/no-go check before you commit: Storage reachable (the destination answers), Backup present on storage (the restore point's data is actually there), Decryptable (the supplied passphrase matches, for age-encrypted jobs – skipped otherwise), and Free space (the staging/destination volume has room for the restore point). Start Restore stays disabled until the checks pass, so a doomed restore fails in a second instead of part-way through. Changing the passphrase or destination re-arms the checks ("Inputs changed since the last check"); when checks fail you can still Restore anyway behind a confirm. Backed by a new
POST /api/v1/jobs/{id}/restore-points/{rpid}/preflightendpoint. - Restore points now show as a visual timeline. The restore wizard's version-selection step replaces the flat card list with a day-grouped timeline plus a density overview (one bar per day, sized by backup volume) so you can see at a glance what you can recover and from when. Each point shows its time, type (full/incremental/differential dot), size, and the trust signals Vault already computes – Recommended (newest), Verified, and chain health (broken / chain depth / retained). Calm, keyboard-navigable, and responsive.
- The 3-2-1 Backup Rule panel is now goal-aware. Instead of always scoring against the full rule (which left a deliberately simple setup permanently red), you pick a backup goal that matches your intent and the panel scores against that: Local only (at least one copy), Local + offsite (a local copy plus a remote/replicated copy), or Full 3-2-1 (3 copies, 2 media types, 1 offsite). It turns green when the chosen goal is met and only suggests the steps relevant to that goal. The goal is a segmented control in the panel; when unset it auto-selects the highest tier your current setup already meets. Persisted in a
backup_rule_goalUI setting (frontend-only). Works alongside the show/hide toggle. - The 3-2-1 Backup Rule panel can now be hidden. The Dashboard's 3-2-1 compliance panel is useful guidance, but for an intentionally simple setup (e.g. a single server backing up locally) it stays permanently red, which reads as a failure rather than a choice. A new × on the panel hides it instantly, and a Settings → Dashboard → Show 3-2-1 Backup Rule toggle re-enables it. It remains shown by default, so the best-practice nudge is still there for anyone who wants it. No scoring changes.
- Per-mount include/exclude toggles for container backups (closes #143). The job wizard's container section (now Container Mounts & Exclusions) lists every bind mount of each selected container with a checkbox – uncheck a mount to keep its data out of the backup (e.g. a media or downloads share mapped into Sonarr/Radarr/Plex). This replaces the slow, error-prone workflow of hand-typing each container path per container. Mounts Vault already auto-skips (device/virtual paths and known shared-data shares) are shown disabled with the reason, so it's clear what will and won't be backed up. A new
GET /api/v1/containers/{name}/mountsendpoint exposes the mount list with each mount's auto-skip verdict. The free-text exclusion box is preserved below as Additional path exclusions for subpaths and glob patterns (e.g./config/Cache,*.log); a checkbox and an exact whole-mount text exclusion stay in sync, so pre-existing exclusions (such as a/rootfsentry on Telegraf/Glances) correctly show their mount as unchecked.
Fixed¶
- The History "Purge" button now warns it deletes restore points. Purging run history runs a cascade that also removes the restore points those runs produced – the recoverable backups Vault tracks – leaving their files orphaned on storage. The confirmation dialog previously said only "delete all job run history records"; it now spells out that restore points go too and that this cannot be undone, so it isn't triggered by surprise.
- Adding an item to a job no longer claims it's already backed up. When you added a container or VM to an existing job, it immediately showed as protected on the Dashboard and appeared in the Restore tab with the job's whole backup history (e.g. "backed up today, 5 restore points") — even though that backup had not run yet, and trying to restore it returned "internal server error". The cause: protection and restore-point listing keyed off job membership rather than what each backup actually captured. Vault already records exactly which items each restore point contains, so it now uses that: such an item shows as "Pending first backup" (a distinct amber state, not green-protected) until a backup really captures it; the Restore tab only lists restore points that contain the selected item; and browsing or restoring an item a restore point never captured now returns a clear message instead of a 500. The health ring's protection figure is likewise based on real per-item membership. (Items backed up before this metadata existed are treated as before.)
- The Dashboard's Backup Health card no longer contradicts itself. The ring (operational health: backup success plus protection of the items you've put in jobs) could read a green 100% while its own subtitle said "Attention needed – 12 items unprotected" – because the subtitle was secretly using a different metric (whole-server coverage, e.g. 8/20). The subtitle now describes the same thing the ring does ("All backups healthy"), and overall coverage is shown as a calm, non-alarming hint ("12 items not in any backup job · see Protection Status") that points at the Protection Status panel, which owns that metric. A deliberately partial setup (backing up only some containers) now stays green instead of looking broken.
- Consistent save buttons across Settings. Every primary action button now behaves the same while working: it shows an inline spinner next to its label (previously only some did – the encryption, API-key, retry-policy, compaction, and staging/snapshot "Apply" buttons just swapped text or did nothing visible). Also fixed seven Settings buttons that used an undefined
hovercolour and so had no hover effect at all, normalised two others that used a different hover shade, and standardised the busy-label punctuation (Saving…rather than a mix ofSaving...andSaving…) and font weight. -
Storage destination cards no longer look uneven. Within a row, the cards already stretched to equal height, but each card's footer (timestamp · job count · health · Test) floated at a different vertical position, leaving a large empty gap under shorter cards. The footer is now pinned to the bottom of every card so the rows line up.
-
Disabling anomaly detection now hides it on backup jobs too. With anomaly detection turned off in Settings, the Learning baseline badge (and the per-job anomaly badge and the job editor's anomaly-sensitivity override) still appeared on backup jobs. The Jobs page now gates all anomaly UI on the global toggle, matching the Dashboard and the Anomalies page, so disabling anomaly detection hides it everywhere until it is re-enabled.
- Selecting a specific NIC bind address now works end-to-end (closes #139). Cycling the Bind Address to a detected interface IP (e.g.
192.168.20.21) left the daemon showing STOPPED and made the Vault UI unreachable – only127.0.0.1and0.0.0.0worked. Two causes are fixed: (1)rc.vaultvalidated the address with a bareipcommand, but the service/php-fpm execution context often lacks/usr/sbininPATH(the same gap fixed forapply.shin #136), so a valid NIC address silently failed validation and was reset to127.0.0.1in memory – the daemon bound loopback while the UI health probe targeted the NIC IP.rc.vaultnow resolves theipbinary to an absolute path and, whenipis unavailable, accepts the configured address rather than rejecting it. (2) Once bound to a NIC IP, every request from the co-located Unraid PHP proxy arrived from a non-loopback source and was rejected with 401 "valid API key required" when an API key was configured – breaking the entire proxied UI, not just the status check. The daemon now exempts requests originating from the host itself (loopback or any local interface address), so the local proxy is trusted on any bind address while genuine remote LAN clients still require a key. - The service status no longer shows a false STOPPED right after applying a configuration change. A restart that takes the hybrid database-restore path can need ~15–20 s to bind and serve health; the post-Apply status poll gave up too early and reported STOPPED until a manual refresh. The poll window now extends to ~36 s.
- Deleting jobs no longer makes the daemon appear to hang (issue #143). After any configuration change (job/storage/settings CRUD) the daemon flushes its database to the USB flash drive; this flush ran synchronously on the request, so deleting several jobs in a row serialised multiple multi-second flash writes and the UI froze (often mistaken for a crash). The flush is now scheduled off the request path and coalesced, so a burst of deletes triggers a single flush and each delete returns immediately. Shutdown durability is unchanged – a synchronous flush still runs before the daemon exits.
- "Delete all files" cleanup no longer fails when a backup directory is already gone (issue #143). Removing a job or restore point with delete files could report "failed – files may remain on storage" purely because a directory it tried to list no longer existed (e.g. a WebDAV
PROPFINDreturning404for an already-removed or never-written path). A missing directory now counts as already-clean instead of aborting the whole cleanup. - Deleting a job on a deduplicated destination now reclaims its storage (issue #143). Dedup backups share a single content-addressed repository (
_vault/) across every job on a destination, so deleting one job previously left its now-orphaned chunks behind. Deleting a job now runs garbage collection to free the chunks no longer referenced by any surviving job, and when the last dedup job on a destination is removed the entire_vaultrepository (and its index) is deleted outright. - WebDAV backups no longer fail with a transient
423 Lockedwhile creating folders. Some WebDAV servers (notably Koofr) briefly lock a directory duringMKCOL– especially right after a failed upload's partial files are cleaned up – so the very nextMkdirAllreturned423and failed the whole item (e.g. "writing config.json to storage: … MkdirAll …/telegraf/: 423"). Directory creation now retries transient WebDAV errors (423,409,429,5xx, network blips) with the same backoff already used for chunk uploads, so a momentary lock clears and the backup proceeds. Verified end-to-end against a live Koofr destination.
Changed¶
- Dashboard VMs card reads "Virtual Machines" instead of "libvirt". The stat card's subtitle showed the implementation name (
libvirt); it now uses the friendlier "Virtual Machines" label. - Item picker empty states follow Unraid's service naming. When adding items to a job and the relevant service is off, the message now reads "Docker service not available" / "Virtual Machine service not available" instead of "Docker is not available" / "libvirt is not available".
- Configuration form on the Unraid plugin settings page is now left-aligned. The Daemon Port and Bind Address labels and fields previously sat in an indented, right-aligned column (Unraid's default
dl/dtstyling); they now sit flush left in a clean label/field layout, matching the Unraid Management Agent plugin's Configuration page. - Em dashes replaced with en dashes throughout the UI. Swept the Svelte/JS web UI, the Unraid plugin settings page (including the Bind Address options), and these release notes (which render in the in-app View Changelog modal) to use en dashes instead of em dashes for a cleaner, more consistent look.
- Bind Address dropdown is grouped and no longer stretches across the screen. The list is organised into labelled sections – Local only (recommended), All interfaces, and Network interfaces (detected NICs shown as
<ip> – <iface>) – and the control is given a fixed, compact width so the Configuration form stays left-aligned instead of spanning the full width of wide displays. - Applying a configuration change reports its result inline instead of as raw script output near the footer. Previously the Apply form streamed
rc.vault's raw output (e.g. "Stopping Vault daemon…") into Unraid's progress area just above the Array Started system-status bar. That output is now suppressed and replaced with a co-located "Applying configuration – restarting daemon…" note next to the form that resolves to the live RUNNING/STOPPED status – keeping the daemon's feedback in-component rather than in Unraid's reserved system-status area, consistent with Unraid plugin conventions and the official Unraid API plugin. - The Dashboard's Protection Status panel grows to fit its contents (issue #143). Each category column (Containers, VMs, Folders, Flash Drive) was capped at a fixed height with an inner scrollbar, so servers with many containers had to scroll within a small box. The columns now size to the number of items, showing every container/VM at a glance without a nested scrollbar.
Removed¶
- The API Endpoints list was removed from Settings → Reference. It enumerated every REST route in the UI, which added clutter without real value for operators (no comparable backup tool surfaces its API inside the app), so it has been dropped. The Reference tab keeps its Compression Guide and Backup Type Guide. Only the in-UI list was removed – the REST endpoints themselves are unaffected, and integrations that call
/api/v1/...continue to work.
[2026.06.04] - 2026-06-13¶
Added¶
- Network auto-discovery via mDNS/zeroconf (closes #137). The daemon now advertises itself on the local network as a
_vault._tcpservice (withversionandtlsTXT records), so integrations such as the Home Assistant Vault integration can auto-discover a running Vault instance instead of requiring a manually entered address. Discovery is automatically disabled when the daemon is bound to a loopback address (the local-only default) – a127.0.0.1/::1bind is never advertised – and enabled for all-interfaces (:24085,0.0.0.0,::) or specific-NIC binds. Pure-Go and Avahi-compatible; advertisement failures are non-fatal and never block backups. - Hide unused services for a cleaner interface. Anomaly Detection and Replication can now be toggled off from Settings. Disabling a feature removes it from the sidebar (and the Dashboard, for Anomaly Detection) and halts its backend work – anomaly evaluation already stops, and scheduled replication syncs are now paused while disabled. Replication defaults to visible only when replication sources are already configured, so existing setups are unaffected and fresh installs stay uncluttered. Replication is always shown on replica instances (it is core there). Re-enabling a feature restores its page and resumes its backend work immediately, without a daemon restart.
Fixed¶
- Backup jobs now fail when all of their targets are missing (closes #138). When every container/VM/folder/plugin/dataset configured for a job no longer exists on the server, the run previously reported completed with zero items backed up, masking the fact that nothing was protected. The runner now records a failed run with a clear error listing the missing targets (and counts them as failed items) so the failure is visible in the UI, notifications, and the reliability detector. Because a missing target is a configuration problem rather than a transient/destination fault, this does not schedule a retry or trip the destination circuit breaker. A safety net also fails any run that ends with zero items processed despite items being configured.
- The Dashboard's Recent Activity now shows the real failure reason instead of "expand for details" (follow-up to #138). The failure-reason helper only surfaced plain-text log lines containing the words "error"/"fail", so messages that contained neither (such as the all-targets-missing message) fell through to a generic "Backup failed – expand for details" – misleading on the Dashboard, which has no expand affordance. It now surfaces the first meaningful line of any plain-text log, and the remaining placeholder reads "see Logs for details". The Recent Activity failure line also now wraps (up to three lines, full text on hover) instead of truncating to a single cut-off line.
- Settings "Save" buttons no longer balloon and show "Loading…" while saving. The Anomaly Detection, Storage Logging, and Discord Save buttons (and the Discord Test / diagnostics buttons) embedded the full-section loading spinner – which carries large padding and a "Loading…" label – directly inside the button. They now use a small inline spinner that keeps the button's shape and colour.
[2026.06.03] - 2026-06-12¶
Changed¶
- S3 key sanitization is no longer silent. When a job or item name contains characters that must be replaced for S3-compatible gateway signing (e.g. spaces –
QA S3 verifyis stored asQA_S3_verify), the daemon now logs the original → stored mapping once per session, so operators browsing the bucket can connect what they see to their job names instead of discovering the rename by accident.
Fixed¶
- The Unraid Plugins page now shows "Vault" with a proper description. The plugin list previously showed a lowercase vault with no description because Unraid's plugin manager renders
/usr/local/emhttp/plugins/<name>/README.mdfor the title and description, falling back to the raw lowercase plugin name when that file is missing. The plugin package (and the dev deploy) now ship that README – title-cased name plus a one-paragraph summary – matching how other plugins present themselves. - The Unraid Plugins page now shows "Vault" with a proper description. The plugin list previously showed a lowercase vault with no description because Unraid's plugin manager renders
/usr/local/emhttp/plugins/<name>/README.mdfor the title and description, falling back to the raw lowercase plugin name when that file is missing. The plugin package (and the dev deploy) now ship that README – title-cased name plus a one-paragraph summary – matching how other plugins present themselves. - Importing backups now skips manifests written by a newer Vault version with a clear log message. Previously a manifest with an unrecognised (future) schema version was silently parsed as a legacy manifest, which could import a backup with wrong or missing settings. Scan and import now check the manifest version and skip too-new manifests with an explicit "upgrade Vault to import this backup" log line; current and legacy manifests import unchanged.
- Bind Address dropdown now lists detected NIC addresses (closes #136). The per-interface addresses (e.g.
192.168.20.21 (br0)) never appeared because PHP silently casts the numeric-string'4'/'6'array keys invault_get_local_ips()to integers, so the strict family comparison never matched and every detected address was filtered out. The keys are now non-numeric (v4/v6), and theipbinary is resolved to an absolute path (/usr/sbin/ip, fallback/sbin/ip) since the webgui's php-fpm PATH may not include/usr/sbin. The loopback and wildcard options (127.0.0.1,::1,0.0.0.0,::) are always present regardless of detection. The Apply-time bind-address validation inapply.shuses the same absolute-path resolution and no longer resets a valid NIC address to127.0.0.1when theipbinary cannot be found. - Activity Log and Replication pages no longer flicker every few seconds (closes #135). When the UI runs through the Unraid plugin proxy it refreshes by polling (5 s on Logs, 10 s on Replication), and every poll swapped the whole list out for a loading spinner and back – a visible jitter/flicker on each tick. Background refreshes now update the list in place; the spinner only shows on first load and explicit user actions (filter change, Refresh). A failed background poll also keeps the current list on screen instead of flashing an error panel, and on Replication it no longer raises an error toast every tick.
- Restoring a chunked WebDAV backup no longer fails with a 404. Restore downloads stream every object through ranged reads (the resumable reader introduced with resilient restores), but the WebDAV adapter's ranged read did not understand chunked (sidecar-manifest) uploads – and a chunked upload has no object at its logical path at all – so restoring any WebDAV backup larger than the chunk size (default 50 MiB) failed immediately. Ranged reads now detect the chunk manifest and serve the requested byte window from only the chunks that overlap it, restoring both the sequential resumable path and the parallel ranged-download path for chunked objects. Plain objects (including dedup pack files) are unaffected and keep their single-request fast path.
- Background backup-file cleanup now reports its outcome (follow-up to #111). Deleting a job or restore point with its backup files returns immediately while the remote sweep runs in the background, but a failed sweep was only visible in the daemon log. A failure is now recorded in the Activity Log (with the storage error) so it is durably visible, and the Jobs page shows a toast when the sweep for a deleted job completes or fails.
- Timed-out finalization steps are now cancelled instead of leaking (follow-up to #112). When a post-backup finalization step (manifest write, database backup, retention sweep) exceeded its hard timeout, the runner moved on – correct – but the hung step's goroutine kept running forever, holding its storage connection open. Each step now receives a context that is cancelled on timeout: in-flight storage I/O is aborted by closing the step's adapter, and the retention and database-backup sweeps stop between items, so the abandoned goroutine exits promptly. A cancelled retention sweep simply resumes on the job's next run.
- Dedup backups can no longer be falsely cancelled by the stall watchdog during long pack uploads (follow-up to #110). The dedup (chunked) backup path only refreshed the no-progress watchdog once per completed file, so a single very large file uploading packs to a slow remote for over two hours could be cancelled mid-transfer. Pack uploads now heartbeat the watchdog on every chunk of bytes written, matching the classic upload path.
[2026.06.02] - 2026-06-10¶
Fixed¶
- Copy API key works on plain-HTTP (non-secure) origins (closes #129). The Copy button under Settings → API Key failed with
Failed to copy – clipboard access deniedin Safari and Chrome because the modern Clipboard API is only available in secure (HTTPS) contexts, and the Unraid web GUI is typically served over plain HTTP on the LAN. Copying now falls back to the legacyexecCommand('copy')path via a shared clipboard helper when the Clipboard API is unavailable or denied. The Activity Log's per-entry copy button uses the same fallback. - Changing the Bind Address (or Port) now reliably restarts the daemon when Apply is clicked (closes #130). The plugin's
apply.shonly restarted the daemon when the PID file was present and valid – a stale or missing PID file silently skipped the restart, leaving the daemon on the old address until a manual restart. It now falls back topidof(the same process lookuprc.vaultuses), prints an explicitRestarting Vault daemon...line in the Apply progress window, and probes the post-restart health check against the actual configured bind address (previously hardcoded to127.0.0.1, which could not confirm a daemon bound to a specific NIC address).
Changed¶
- VM and folder backup progress now shows human-readable sizes (closes #133). Live progress messages such as
backup in progress: 6453198848/34359738368 bytesnow readbackup in progress: 6 GB/32 GB. The same formatting applies to VM disk restore, NVRAM copy, stale block-job wait, and the folder-backup manifest completion message. - Anomaly detail values are now human-friendly (closes #134). Anomaly details stored and shown in notifications, the API, and the UI previously carried full float precision (e.g.
Z Score: -16.76413455138884,Growth Factor: 0.5766537578335602). New anomalies are rounded at the source (z-score and growth factor to 2 decimals, capacity ETA to 1 decimal, slope/intercept to whole bytes, free-space percentage to 1 decimal), and the Activity Log formats these fields at display time so entries recorded before this change read cleanly too.
[2026.06.01] - 2026-06-08¶
Changed¶
- Renamed "Grandfather-Father-Son (GFS)" retention to "Long-Term Retention (LTR)" across the UI, code, and docs (closes #125). LTR is the industry-standard enterprise term for keeping weekly/monthly/yearly recovery points (as used by Veeam, Druva, and others) and reads far more clearly than the legacy tape-rotation name. This is a terminology-only change – no database, API, or behaviour changes: the
keep_latest/keep_daily/keep_weekly/keep_monthly/keep_yearlyjob fields and theGET /jobs/{id}/retention-previewendpoint are unchanged. - Collapsible API reference (closes #126). The API Endpoints list under Settings → Reference is now split into collapsible sections (Health & Realtime, Jobs, Storage Destinations, Deduplication, and so on), each showing its endpoint count and collapsed by default, so the reference stays tidy and easy to scan as the API grows.
- Clearer help tooltips. Rewrote the tooltips for the more technical storage and settings options – WebDAV/S3 transfer tuning (chunk/part size, stall and request timeouts, path-style, bandwidth), dedup compaction threshold, database mode, and scheduled/backup verification – into plain language with a consistent what it does → why it matters → recommended default structure. No behaviour change.
Fixed¶
- Changing the daemon port no longer silently breaks the web UI (closes #124). Previously an invalid or already-in-use port was written straight to
vault.cfg(the port field's1024-65535limit was enforced only client-side), the daemon failed to bind and exited, and the UI became unreachable with no feedback. The plugin'sapply.shnow validates the port server-side – rejecting non-numeric or out-of-range values and resetting to the default24085– then verifies the daemon actually comes back up after the restart and prints a clear diagnostic (with the tail of the log) if it does not.rc.vaultnow re-checks the process for a few seconds after launch and dumps the last log lines on a failed start instead of leaving a stale PID file. The Settings page polls the daemon's real status after an Apply and shows a visible error (instead of only a transient progress frame) when the chosen port can't be bound.
[2026.06.00] - 2026-06-07¶
Fixed¶
- Large backups no longer fail with
dedup: pack size … exceeds safety boundwhen an item contains a very large number of files (#118). Backup manifests (the per-item index of every file and its chunks) were stored as a single JSON blob; for a directory with an enormous number of files – e.g. a Plex appdata/tmptree – that blob grew past the packer's 96 MiB safety bound and the whole backup aborted. Oversized manifests are now split through the same content-defined chunker used for file data and stored as ordered segments behind a small envelope, so no single chunk approaches the bound regardless of file count. The split is transparent on restore, existing (v1) single-blob manifests still read unchanged, and because near-identical manifests now mostly re-reference existing segments, manifest storage also deduplicates across snapshots. A clearer early error also replaces the opaque packer failure if any oversized chunk is ever stored. - Anomaly values are now human-readable, and the "Raw details" toggle works. Anomaly summaries and the detail panel showed raw numbers – e.g.
backup size anomaly: 4258918530 bytes,Observed: 4259532913,Deviation: -27.28833643436231– and clicking Raw details did nothing. Values now adapt to friendly units (bytes → KB/MB/GB, seconds →5m 26s, ETA → days) on both the summary line and the Observed/Expected fields, and the deviation z-score is rounded to two decimals. The Raw details disclosure was a<details>nested inside the row's<button>(invalid HTML), so clicks collapsed the row instead of expanding; it is now a sibling of the row and expands correctly to show the raw detector JSON. Both the summary headline and the detail panel are reformatted at display time, so anomalies stored before this change are humanized too; newly-detected anomalies are additionally stored with friendly summaries (so Unraid/Discord notifications read the same way). - The VM backup mode (Live Snapshot vs Cold Backup) now saves and reloads correctly when editing a job (#116). The job editor never persisted the choice:
saveJob()strippedvm_modefrom the API payload before sending it, andopenEdit()hardcoded the selector back to Live Snapshot every time a job was opened – so picking Cold Backup appeared to work but silently reverted, and every VM backup ran in snapshot mode regardless of the setting. The editor now sendsvm_modeon save and reads the saved value back on edit (defaulting tosnapshotfor jobs created before the field existed), so Cold Backup sticks. This matters because Cold Backup (shut down → copy → restart) is the reliable fallback when a live snapshot backup fails – e.g. on non-qcow2 disks or guests without a working dirty-bitmap path. - Failed-backup error messages are now visible at a glance on the History page (#116). Previously the failure reason was clipped to ~120 characters and the inline summary was
truncated to one line, so the actual cause (e.g. a libvirt/QEMU or storage error behind a VM backup that fails partway) was effectively hidden – matching the "just seeing a message of failed, no log errors I can see" report. The inline reason now wraps (up to three lines, full text in the tooltip) and is no longer character-clipped, the most recent failed/partial runs auto-expand to show the per-item breakdown without a manual click, andpartialruns (some items succeeded, some failed) now surface their first error too. - Remote backups are no longer cancelled mid-upload by the progress watchdog or a hidden total-job timeout (#110). The per-job no-progress watchdog only advanced its timer during the staging phase, so the actual remote upload – a single blocking
adapter.Write– ran with a frozen timer and any upload exceeding 2 hours was killed even while data was transferring fine (worst with Defer remote upload, where the whole batch uploads with the timer frozen). The upload (and verify read-back) now heartbeat the watchdog on every chunk of bytes moved, so it only fires on a genuinely dead connection. Separately, the undocumented 4-hour total-job hard cap that also cancelled large healthy backups has been removed: a job that is actively transferring is never killed by elapsed time. The no-progress stall watchdog (zero bytes for 2h) plus manual Cancel are now the only automatic cancellers, and a stall-cancel is logged and labelled distinctly from a user cancel (and still triggers a retry, unlike a user cancel). - Deleting a job or restore point with its backup files no longer times out the UI ("daemon unavailable") on slow remotes (#111). Cleanup of a large backup on a remote target (e.g. ~30 GB on a Hetzner Storage Box) ran synchronously inside the HTTP handler and could take far longer than the browser/proxy would wait – surfacing as a spurious
vault daemon unavailableeven though the server kept working and eventually returned 204. The DB record is now deleted immediately and the remote file sweep runs in a background goroutine; the request returns 202 Accepted and ajob_cleanup_complete/restore_point_cleanup_complete(or*_failed) WebSocket event reports the outcome. Cleanup also no longer leaves empty WebDAV chunk sidecar directories behind:Deletenow removes the.chunksdirectory after its chunk files, sweeping orphaned artifacts from partial/interrupted chunked uploads. - Storage destinations can now be deleted even when jobs or replication sources still reference them (#113). Deleting a destination with dependent rows previously failed with an "internal error" because
jobs.storage_dest_idandreplication_sources.storage_dest_idare foreign keys with noON DELETErule, so the bareDELETEraised aFOREIGN KEY constraint failed.DeleteStorageDestinationnow runs in a single transaction that first orphans those dependents – setting the reference toNULLso the jobs survive but lose their destination (matching the delete dialog's warning that "those jobs will no longer have a storage destination and will fail to run") – then deletes the row. Job reads nowCOALESCE(storage_dest_id, 0)and job writes map the0sentinel back to SQLNULL, so an orphaned job lists, opens, and re-saves without re-triggering the constraint. Destinations referenced only throughON DELETE CASCADEtables (dedup packs/chunks/GC runs, capacity samples) continue to clean up automatically. - Dashboard no longer stays stuck "Backup in Progress" forever when a finalization step hangs (#112). Three coordinated fixes: (1) The Unraid notify helper is now launched with a 30-second
context.WithTimeoutviaexec.CommandContext; a wedged helper is killed and a clear error is logged rather than blocking forever. (2) The live run status (currentRun) is cleared immediately after the run is persisted to the database – the Dashboard reflects completion the moment data is safe, even if database-backup, USB flush, retention enforcement, or notification takes extra time. The existing deferredsetRunStatus(nil)remains as an idempotent safety net. (3) Every blocking finalization step (database-backup,snapshot-usb,retention/retention-gfs,notify) is now wrapped inrunFinalizationStepwith a named hard timeout (5 min / 5 min / 10 min / 2 min). On timeout anERRORlog line names the step and the run continues, ensuringr.mu(the run-slot mutex) always releases and future jobs are never blocked indefinitely.
Changed¶
- In-app API Endpoint Reference (Settings → API Endpoints) now lists the anomaly-detection and stale-item remediation endpoints, so the documented surface matches the daemon's actual routes (
/anomalies*,/jobs/{id}/baseline,/destinations/{id}/capacity-trajectory, and the new/jobs/{id}/stale-items*+/jobs/{id}/items/{itemId}). - Storage layer rebuilt as a composable middleware chain for uniform reliability across all backends. Every storage destination (local, SMB, NFS, SFTP, WebDAV, S3) is now a thin provider wrapped in a chain – throttle → retry → metrics → logging – so cross-cutting behaviour is identical everywhere instead of hand-rolled per provider. A single transient-error retry layer with jittered exponential backoff (5 attempts, 1s→30s, full jitter) now sits in front of all backends; it classifies timeouts, connection resets, broken pipes, unexpected EOF, and transient HTTP statuses (408/429/500/502/503/504) as retryable while failing fast on auth/permission, 4xx, and user cancellation. This replaces the previous WebDAV-only retry loop and gives S3 and SFTP automatic retry they never had. A new retry-aware
WriteFromwrite path re-opens the staged source per attempt so a partially-failed upload replays cleanly from the start.
Added¶
- Stale-item detection and one-click remediation for backup jobs (#119). When a container, VM, folder, plugin, or ZFS dataset referenced by a job no longer exists on the server, the backup run now skips it instead of failing the whole job with confusing "missing item" errors, records it, and raises an Unraid notification. The Jobs page shows an amber "N missing" badge on affected jobs; clicking it opens a Review missing items dialog that lists each gone item with a per-item Remove button and a Remove all button. Vault never removes items automatically – you decide. Removal is re-validated server-side at click time (an item that has reappeared is left alone) and only drops the item from the job: existing backups and restore points are preserved, so you can still restore what was captured before the source disappeared. Detection degrades safely – if Docker or libvirt is temporarily unavailable, items of that type are treated as "unknown" (never flagged as missing) so a transient outage can't mark everything stale. New endpoints:
GET /api/v1/jobs/{id}/stale-items,POST /api/v1/jobs/{id}/stale-items/remove,DELETE /api/v1/jobs/{id}/items/{itemId}; new WebSocket eventstale_items_detected. - Resilient, parallel restores from remote storage. Restore downloads now recover from transient mid-stream connection drops instead of restarting the whole object and failing the restore: a resumable reader reopens the transfer from the last byte received (built on ranged reads, so it works on every backend – local, SMB, NFS, SFTP, WebDAV, S3). Restores also download multiple files concurrently (bounded by the job's parallel-transfers setting), and a single large plain object (e.g. an unencrypted, uncompressed VM
qcow2) is fetched via concurrent ranged GETs assembled on disk – turning a long single-stream download into a parallel one. Encrypted (.age) or compressed objects use the sequential resumable path so the decrypt/decompress pipeline stays correct; checksums are verified on both paths (a whole-file re-read for the parallel path, inline for the sequential path). Restores additionally gained the same no-progress stall watchdog the backup path has – a hung restore is now cancelled (and that cancellation reaches the engine restore phase) rather than blocking indefinitely. - Per-job parallel uploads. Each job has a Max parallel uploads setting (1–16). A job's staged files now upload through a bounded worker pool of that size, sharing the destination's bandwidth cap. Existing jobs keep serial behaviour (1) on upgrade so nothing changes unexpectedly on fragile links; new jobs default to 3. Set it to 1 for a flaky or rate-limited remote, or higher for a fast link.
- SFTP connection pooling. The SFTP backend previously dialed a fresh SSH connection for every operation; it now reuses a small pool of healthy connections (and correctly closes both the SSH and SFTP clients on teardown, fixing a per-operation connection leak). This makes parallel SFTP uploads practical.
- Storage observability. Per-operation metrics (calls, errors, retries, latency) are tracked per destination, and an opt-in Verbose storage logging toggle (Settings → General, off by default) emits a redacted, structured trace line per storage operation for diagnosing slow or flaky remotes.
-
Empty-directory cleanup. Deleting the last backup under a directory on filesystem-style and SFTP backends now sweeps the now-empty parent directories, so destinations don't accumulate empty folders over time.
-
Anomaly detection and insights (Pillar A). A new
internal/anomalyengine watches four signals per job/destination – size drift, duration drift, reliability/verify regressions, and destination capacity trajectory – and surfaces anomalies across every Vault surface. Detectors use robust statistics (median + MAD with the Iglewicz–Hoaglin modified z-score) plus pragmatic secondary rules (a 5× median-multiplier growth rule and a 0.5× asymmetric-shrinkage rule for suspiciously small backups). Each job and destination exposes a Strict / Balanced / Permissive sensitivity knob (Settings → General sets the global default; per-job overrides on the job edit form). Detection runs asynchronously off the backup runner via a bounded, non-blocking evaluator goroutine – a completed run enqueues an evaluation, and a 5-minute trend ticker drives the capacity detector. Anomalies surface live on a new dashboard card, as inline severity badges on job and run-history rows, on a new/anomaliespage (filters, keyset pagination, bulk-acknowledge), via Unraid + Discord notifications (severity-gated; default: critical only, withnotified_atde-duplication and per-jobanomaly:*notify_onoverrides), and as three MCP tools (list_anomalies,get_anomaly,acknowledge_anomaly) for AI assistants. Lifecycle is hybrid: soft (info/warning) anomalies auto-resolve on a clean run, criticals persist until acknowledged, and a Mark as expected action raises the baseline floor so deliberate growth stops re-alerting. New endpoints under/api/v1/anomalies,/api/v1/jobs/{id}/baseline, and/api/v1/destinations/{id}/capacity-trajectory; new WebSocket eventsanomaly.raised,anomaly.updated,anomaly.bulk_resolved,anomaly.bulk_acked, andbaseline.updated. Detection is opt-out (anomaly_detection_enabled, defaults totrue) and learns a per-job baseline over the first 10 runs (the UI shows a "Learning baseline (N/10)" label until then). Storage adapters gained aUsage()method (free/total bytes) – implemented for local, SFTP, SMB, NFS, and quota-capable WebDAV; S3 reports unsupported. Terminal anomalies older than 90 days are pruned automatically.
Security¶
- SFTP private-key file path is now validated before it is read (CodeQL
go/path-injection). The admin-configured key-file path is rejected if it contains any..traversal component (checked both before and after normalisation) and resolved to a clean absolute path beforeos.ReadFile, mirroring the existing snapshot-path sanitiser. Static-analysis false positives on the parameterised bulk job-item queries (DELETE/UPDATE … WHERE id IN (?, …)) and the anomaly-list filter query were annotated as safe – every value is bound, never interpolated.
[v2026.05.04] - 2026-05-28¶
Fixed¶
-
Vault configuration no longer disappears after an Unraid upgrade or restart (closes #108). Four root-cause-driven changes restore the persistence invariant end-to-end:
-
Non-hybrid USB-backup restoration fallback. The previous startup path only ran the snapshot fallback chain when hybrid mode engaged. The first time the daemon ran in hybrid mode, the on-USB
vault.dbwas renamed tovault.db.backup(the safety net). On any later boot where the pool drive failed to mount in time – exactly what happens after an Unraid upgrade restart, because Unraid launches plugin services before the array finishes mounting –hybridModestayed false,db.Open("/boot/.../vault.db")saw a missing file and silently created a brand-new empty SQLite schema, and every saved job, destination, and setting became invisible.internal/cli/daemon.gonow performs an explicit non-hybrid restoration step: if the primary DB is missing/empty but a USB safety-net backup exists,vault.db.backupis copied back into place (fsynced) before the firstdb.Open. Operators get a clear log line (Configuration restored from USB backup at …) and, if neither file exists, an explicit warning that the daemon is starting fresh. - Pool-mount retry loop on startup.
internal/cli/daemon.gonow pollsunraid.PreferredPool()+unraid.IsMountedPool()up to 15 times with a 2 s interval (30 s window total) before declaring no pool. This catches the common case where the array comes up within a few seconds after the daemon launches and lets hybrid mode engage cleanly, instead of falling through to USB-direct mode for the entire daemon lifetime. - Pre-drain USB flush on SIGTERM.
internal/api/server.goStartWithContextnow invokes a registeredpreShutdownHookimmediately onctx.Done(), before the runner drain. The daemon registerssnapshotMgr.FlushToUSB()as that hook (internal/cli/daemon.go). Previously the final snapshot + USB flush only happened after the (up to 30 s) runner drain returned andsrv.ListenAndServeproducedErrServerClosed; ifrc.vaultescalated toSIGKILLmid-drain (which it did, given a 15 s wait), all in-RAM configuration was lost. A new log lineserver shutdown: pre-drain flush completed in <duration>confirms the path ran. - rc.vault shutdown window extended from 15 s to 45 s with intermediate SIGINT. The plugin's service script (
plugin/rc.vault) and the Ansible deploy role's inlinerc.vaulttemplate (ansible/roles/deploy/tasks/main.yml) now both: pollkill -0 $pidfor up to 45 s after SIGTERM (matching the 30 s drain + flush headroom), send SIGINT before escalating to SIGKILL, and log forced-shutdown reasons. The previous one-linestop()(kill $pid; rm -f $PIDFILE; echo "stopped") returned in milliseconds without waiting, which is what introduced the SQLITE_BUSY race observed duringmake deploy(the new daemon started before the old one finished its flush).
Verified end-to-end on Unraid against the live "Test Containers", "Folders Backup", and other configured jobs: every rc.vault restart cycle now logs server shutdown: pre-drain flush completed in 1.2-3.0 s → Vault daemon stopped gracefully → fresh startup with Database restoration: source=primary path=/mnt/cache/.vault/vault.db (restored from configured snapshot path). Configuration survives across multiple back-to-back restarts with zero database is locked errors.
Added¶
-
Startup persistence diagnostics on
/api/v1/health. The health endpoint now includes astartupobject exposing the boot-time facts the daemon learned during initialisation:hybrid_mode,detected_pool,pool_mounted,pool_retry_wait_ms(how long the pool-mount retry loop waited before declaring success), therestorationsource/path/reason,has_configuration(true iff the DB holds ≥1 job or ≥1 storage destination), a fullconfigurationrow-count breakdown, andunraid_boot_kind("flash"for traditional USB boot,"internal"for Unraid 7.3+ internal boot,"unknown"for non-Linux dev hosts). Support engineers (andmake verify) can confirm at a glance whether configuration survived an Unraid restart or upgrade without parsing the daemon log. Backed by a new lightweightdb.ValidateHasConfiguration(ctx)helper (internal/db/validation.go) that runs three indexedCOUNT(*)queries againstjobs,storage_destinations, andsettings. -
Prominent startup warning when the database appears empty after restoration. Right after
db.Open, the daemon now callsValidateHasConfigurationand either logsDatabase configuration validated: N jobs, M storage destinations, K settings(the happy path) or, when both counts are zero, emits a singleWARNING:line that names which snapshot/USB-backup paths were attempted and links to issue #108. Catches the silent-fresh-database failure mode that previously needed a support bundle to diagnose. -
Boot-time startup delay in
rc.vault(plugin/rc.vault+ the Ansible deploy role's inline). Read/proc/uptime; if the kernel has been up for less than 120 s, sleep 8 s before launching the daemon. Manual restarts (deploy iteration,rc.vault restartfrom the command line) skip the delay entirely – the check is purely a belt-and-braces measure complementing the in-daemon 30 s pool-mount retry loop, for the case where Unraid's plugin startup races the array mount on first boot. No effect on iteration speed; only the first ~2 minutes after kernel boot pay the 8 s cost. -
Periodic USB safety-net refresh every 30 min (hybrid mode only). Belt-and-braces for the case where the event-driven flushes –
configChangeHookon every mutation, post-backup snapshot save, SIGTERM pre-drain flush – all happen to be quiet for a long stretch (e.g. an idle system that loses power unexpectedly). A background ticker callsSnapshotManager.SaveSnapshotAndUSBBackup()every 30 minutes; the pool-snapshot write always runs (zero flash wear), while the USB-flash write is gated by the existing 1-hourusbMinIntervalthrottle, bounding worst-case wear to one USB write per hour regardless of cadence. On Unraid 7.3 internal-boot systems where wear is essentially not a concern, the throttle just means the USB-equivalent backup stays at most one hour fresh. Skipped in non-hybrid mode because the working DB is the USB file there, so a backup would be a no-op self-copy.
Changed¶
-
db.RestorationInfofields now serialise assource,path,reason(snake_case JSON tags) to match the rest of the API surface. Was previously emittingSource,Path,ReasonPascalCase, which was inconsistent with every other Vault JSON response. The Go field names are unchanged so no internal callers move. -
Unraid 7.3 internal-boot compatibility documented (and confirmed safe). Unraid 7.3.0 (stable, released 12 May 2026) introduces internal boot – booting Unraid from an internal NVMe/SSD/eMMC (optionally a ZFS-mirrored boot pool) instead of a USB flash drive. Vault's persistence model relies on
/boot/config/plugins/vault/vault.db.backupas the safety-net copy. On Unraid 7.3 internal boot,/boot/is still the canonical mount point for the boot device'sconfigpartition – Unraid copies the flash content to the internal device during the onboarding-wizard migration, so existing Vault installs that survive the OS upgrade keep their settings without code changes. The daemon now reportsunraid_boot_kindin/api/v1/health("flash"or"internal") so support can verify which path is in use, and a few user-facing log messages drop the obsolete "USB flash" wording in favour of "boot device" / "boot flash" where the difference matters. Migration path for existing users: do the standard Unraid 7.3 internal-boot migration; Vault settings carry over because they live in theconfigdirectory that Unraid copies. Two boot devices in a ZFS mirror also adds redundancy to Vault's USB safety-net (one boot device can fail without losingvault.db.backup). -
Docker (
vault-replica) build now copiesCHANGELOG.mdintointernal/release/beforego build. The 2026.05.03 Docker workflow failed withinternal/release/embed.go:7: pattern CHANGELOG.md: no matching files foundbecause the//go:embeddirective added with the About card requires the file next toembed.goat build time. The Makefile and the Ansible build role already do that copy; the Dockerfile didn't (the embed-location copy is gitignored). The published GitHub release (vault-2026.05.03.txz) was unaffected because it ships through the Makefile path. Adds thecpstep in the builder stage ofDockerfile. -
Settings → About card now reports a daemon ahead of the latest GitHub release as "Pre-release" rather than the misleading "Update available".
web/src/pages/Settings.sveltepreviously treated any version inequality as "Update available", so a freshly-tagged build (or a development build that's ahead of the most recent published release) wore the same orange "Update available" badge a genuinely out-of-date daemon would. The check now uses an ordering comparison on the normalizedYYYY.MM.PATCHstrings (which sort chronologically because the patch is 0-padded):daemon === latest→ "Up to date",daemon > latest→ "Pre-release" (withLatest published: vX.Y.Znote),daemon < latest→ "Update available". Closes the unreleased Known issue tracked here.
[v2026.05.03] - 2026-05-27¶
Added¶
- Per-destination storage capacity on the Storage page. Every backup destination now shows how much space it has left, refreshed daily alongside the 03:30 health-check sweep. Quota-aware providers (
localandnfsviaunix.Statfs;webdavvia RFC 4331 PROPFIND on Nextcloud / ownCloud;sftpvia thestatvfs@openssh.comextension;smbviaFileFsFullSizeInformation) render a coloured progress bar – Vault brand below 80 %, amber 80–90 %, rose ≥ 90 %. Providers without a protocol-level quota (S3 / Backblaze B2) render a "used-only" line whose number comes from a paginatedListObjectsV2sum under the destination's base path. New endpointPOST /api/v1/storage/{id}/capacity-checkruns an on-demand probe with a 30 s ceiling and broadcastsstorage_capacity_updatedon the WebSocket so any open Storage page repaints live. Six new nullable columns onstorage_destinations(capacity_total_bytes,capacity_used_bytes,capacity_free_bytes,capacity_probed_at,capacity_source,capacity_error) carry the persisted reading; the daily scheduler ceiling is 60 s; failures are stored incapacity_errorfor support reports but never affect the health-check verdict or breaker. All values render through the existingformatBytes()helper so operators see KB / MB / GB / TB / PB, never rawint64. The API returns rawint64bytes for machine consumers (MCP tools, scripts, diagnostics bundle); only the UI does the conversion. - About card on Settings → General. Compact card at the bottom of the General tab showing the daemon's current version, an "Up to date" / "Update available" status badge backed by a 1-hour-cached GitHub releases query, and a "View Changelog" button that opens a modal listing every shipped release in collapsible form (latest auto-expanded; click any row to expand it). The full release history lives inside the modal so the Settings page itself stays uncluttered. New endpoints
GET /api/v1/release/changelog(serves the embeddedCHANGELOG.mdparsed by version, Unreleased excluded) andGET /api/v1/release/latest(returns the latest GitHub release tag with a 1 h server-side cache; 204 when GitHub is unreachable so the card renders a clean offline state). Version comparison normalises the leadingvso a daemon reporting2026.05.02matches a GitHub tag likev2026.05.02.CHANGELOG.mdis embedded into the binary via//go:embedat build time (the Makefile and Ansible build role copy the repo-root file into the embed location; the copy is gitignored to keep the repo root the single source of truth). - Run-level retry policy. Failed runs now schedule themselves for automatic retry up to a configurable cap before going permanently failed. Settings exposes a global default (max retries + JSON delays array, defaults
2and[900,3600,14400]seconds → 15 min / 1 h / 4 h). Each job can override both fields in the wizard's Advanced step. A new scheduler watcher runs@every 1m, atomically claims rows whoseretry_next_athas expired, and dispatches via a newRunJobRetry(jobID, originalRunID, attempt)path. Triggers retries on: stall cancellation, pre-flight failure, encryption-config error, pre-script failure, panic recovery, and terminalfailedstatus. Suppresses retries when the destination breaker is open, when the run was operator-cancelled, or when the run was a manual "run now" (the API/MCP "run now" callers now route throughRunJobManualso they don't auto-retry). - Pre-flight destination check. Every scheduled run runs a fast
TestConnection(15 s ceiling) against the destination before opening the backup pipeline. Unreachable destinations now produce askippedjob_runrow with a clear reason instead of consuming the 4-hour job timeout. The skip counts toward the circuit breaker so a chronically unreachable destination eventually opens its breaker. - Per-destination circuit breaker. Each storage destination tracks
consecutive_failuresand abreaker_state(closed/open). The breaker opens after 3 consecutive failures (job runs OR daily health checks OR preflight) and auto-closes after 2 consecutive successes. While open, scheduled runs targeting the destination skip with a clear reason instead of failing. NewPOST /api/v1/storage/{id}/breaker/closeendpoint resets the breaker manually (204). Storage page shows a "Breaker open" badge with the timestamp tooltip and a "Reset breaker" button when the breaker is open. Encryption/pre-script failures and operator cancellations intentionally do NOT count against the breaker (they're config issues, not destination health). - Heartbeat file at
/var/local/vault/heartbeat(hybrid mode) or alongsidevault.db. Refreshed every 30 s with<RFC3339 timestamp> v<version>so external monitoring (Unraid notifications, monit, custom watchdog) can detect a hung daemon by checking the file mtime. RAM-backed in hybrid mode so it doesn't wear flash. Write failures are logged at most once per hour to avoid log spam. - Graceful drain on SIGTERM. Daemon shutdown now waits up to 30 s for the active backup to finish at the next per-file boundary before tearing down the HTTP server. After the drain ceiling fires, the existing context-cancel still terminates mid-flight work. New
Runner.Drain(ctx)API; runner refuses newRunJobinvocations once draining. - Encrypted offsite database backup with fan-out. The post-backup DB copy now (a) runs
PRAGMA wal_checkpoint(TRUNCATE)so the on-disk file is self-contained, (b) encrypts with the global passphrase when one is configured (writes age-format.age; plaintext + warning log otherwise), (c) writes both a timestamped historical copy (_vault/vault.db.YYYY-MM-DDTHH-MM-SS.age) and a stable_vault/vault.db.latest.agepointer, and (d) fans out to every destination flagged "Include in DB backup" in addition to the job's own destination. Newbackup_database_enabledcolumn onstorage_destinations(default off). Storage page exposes the toggle as "Include in DB backup". - Rolling snapshot history (keep last 7). After every successful primary snapshot save, the file is also copied to
<snapshot_dir>/rotated/vault.db.YYYY-MM-DDTHH-MM-SS.nanosand the rotated directory is pruned to the 7 newest entries. Uses a hardlink on the same filesystem (zero extra bytes for unchanged snapshots) and falls back to a byte copy on cross-device errors. Filenames use UTC dashes-only timestamps with nanosecond resolution so they sort chronologically and never collide. Rotation failures are logged but never block the snapshot pipeline, so a transient disk issue cannot poison the main save path. - Per-destination deduplication (opt-in). Storage destinations gain an "Enable deduplication" toggle (immutable after creation). When enabled, folder, plugin, and container-volume backups to that destination are content-defined-chunked, encrypted per-chunk, and packed so identical data across snapshots, jobs, and containers is stored once. VM, ZFS, and flash backups continue to use the classic tar pipeline. The Storage card shows the dedup ratio, chunk/pack counts, reclaimable bytes (the space a cleanup could reclaim, measured at the last GC and persisted in a new
dedup_gc_runstable so the figure and last-cleanup time survive daemon restarts and are visible from any request – they no longer read 0), and a "Run cleanup" button. "Run cleanup" now also compacts mixed packs (those above a configurable dead-bytes threshold): live chunks are copied verbatim into fresh packs (no decrypt/re-encrypt – chunk encryption is deterministic and keyed by content ID, so the on-disk bytes are identical regardless of which pack holds them), chunks are atomically re-pointed in the DB, and the old packs are tombstoned + deleted. Chunks remain readable from at least one pack at every observable step. The threshold lives in Settings → General as "Compaction threshold (% dead bytes)" (keydedup_compaction_min_dead_ratio, default0.5= 50%; set to 100 to disable compaction). The on-storage_vault/index/*.idxlog gained a tombstone record + last-writer-wins rebuild semantics sovault dedup repaircorrectly removes deleted packs (and re-points moved chunks) during a disaster-recovery rebuild – closes a latent bug where the rebuild path would resurrect packs that GC had deleted. GC's sweep also now reaps empty packs (created if a compaction crashes between writing the new pack and re-pointing chunks) so any orphan from a partial compaction self-cleans on the next run. Thededup_gc_runstable gainedcompacted_packsandreclaimed_bytescolumns; both surface in thededup_gc_completeWebSocket payload and the daemon log line. New endpointsGET /api/v1/storage/{id}/dedup-statsandPOST /api/v1/storage/{id}/gc. New CLI subcommandsvault dedup repair --dest=<id>(rebuild the SQLite index from on-storage data after DB loss) andvault dedup gc --dest=<id>; both accept a--keyflag to readvault.keyfrom a non-default location (e.g. a disaster-recovery restore where the key was placed beside a copied database). Quick + deep verify both work on dedup restore points without UI changes. - Restore wizard file picker. The restore wizard's summary step now has a "Restore specific files…" disclosure per item. Pick individual files (or whole subtrees) with a path filter, select-all, and clear. Items left untouched still restore in full. Falls back to whole-archive extract for backups produced before partial restore shipped.
- Partial restore via tar index sidecar. Folder, plugin, and container-volume backups now write a small index sidecar (
<archive>.index.json) next to each tar archive listing every entry. The restore API accepts an optionalfile_pathsmap so only the named entries are extracted (descendants of selected directories are included automatically). Sidecars are encrypted alongside the archive when the job uses encryption. Pre-existing backups continue to restore everything via the legacy whole-archive path. - Grandfather-father-son (GFS) retention. New optional
keep_latest,keep_daily,keep_weekly,keep_monthly,keep_yearlyfields per job (all default 0 = disabled). When any are set, the runner keeps the newest N latest plus the first restore point per day/week/month/year bucket. A single restore point can fill several buckets at once. Existingretention_count/retention_daysare unchanged and continue to apply on every other job. Chain-ancestor protection still applies so kept incrementals always have a restorable parent. - GFS retention preview in the Jobs wizard. As users tune the keep counts, the wizard shows "Would keep X of Y current restore points · Z would be pruned" 300 ms after the last keystroke. Backed by a new
GET /api/v1/jobs/{id}/retention-previewendpoint. Hidden for new jobs that don't have restore points yet. - Restore-point verification with quick and deep modes.
POST /api/v1/jobs/{id}/restore-points/{rpid}/verifyqueues a verify run and returns averify_run_id; progress streams over the WebSocket. Quick stats every recorded file and fails on missing files or size mismatches (no data transfer, suitable for weekly scheduled runs). Deep streams every file and re-checks SHA-256 against the backup-time manifest – catches silent bit rot on cold storage. Two helper endpoints round out the API (GET .../verify-runs/{vrid}andGET .../restore-points/{rpid}/verify-runs). Verify runs in its own goroutine and does not block scheduled backups. - Scheduled verification per job. Two new job columns (
verify_schedule,verify_mode) let you run quick or deep verify on its own cadence, independent of the backup schedule (e.g. nightly backups + weekly deep verify). Configured in the wizard's Advanced step via the same friendly day/time picker used for the backup schedule; no cron expertise required. - Daily storage destination health check. The scheduler runs
TestConnectionagainst every destination at 03:30 local (deliberately after typical backup windows so a failing destination is caught before the next morning's run). Results persist on the destination row and broadcast over the WebSocket. The Storage page shows a Healthy / Unhealthy badge with the error in the tooltip.POST /api/v1/storage/{id}/health-checktriggers an on-demand check. - Orphan file GC (dry-run by default).
POST /api/v1/storage/{id}/scan-orphanslists files on a destination that don't belong to any active restore point. Safe to call repeatedly; no state changes.POST /api/v1/storage/{id}/delete-orphansdeletes specified paths after re-scanning to ensure each is still orphaned. Useful for reclaiming quota on managed providers after failed runs. UI is API-only in v1. - Per-destination bandwidth throttling. Optional
bandwidth_limit_mbpsfield on every non-local storage config caps upload + download throughput (megabits per second). Default 0 = unlimited. Metadata operations (List/Stat/Delete/TestConnection) are never throttled. Exposed in the Storage form as a single "Bandwidth limit (Mbps)" input. - Configurable S3 multipart part size (
part_size_mb). Default 0 uses 64 MiB parts (640 GB per-object ceiling, fits typical home-server workloads). Raise to 256, 512, or 1024 MiB for very large datasets (up to 10 TB). Range is enforced at 5–5120 MiB per the S3 protocol. Peak upload memory ≈ part_size × concurrency (default 5), so 1 GiB parts cost roughly 5 GiB of RAM during active uploads. - Configurable WebDAV chunk size (
chunk_size_mb). Default 0 uses 50 MiB chunks. Positive values set the chunk size in MiB. Negative values disable chunking but keep memory-safe known-length writes. - Automatic compression downgrade for media-heavy backups. Folder, plugin, and container-volume backups now sample the source up-front and, when ≥ 70% of the bytes have already-compressed file extensions (.mp4, .mkv, .flac, .jpg, .zip, .gz, .zst, …), automatically drop the requested gzip/zstd compression to none for that archive. A 200 GB Immich library or a Jellyfin folder no longer wastes CPU re-compressing already-compressed bytes. The decision is logged and the manifest records the compression actually used so restore continues to auto-detect via magic bytes.
- Diagnostics bundle captures the daemon log, per-job item settings, storage health-check trend, runtime settings, and disk space. Every bug we triaged in recent QA was diagnosed by reading the daemon log, inspecting per-job item settings, or checking storage health history – none of which were in the previous bundle. The collector now tees daemon output through a 1 MiB in-memory ring buffer at startup and includes the redacted snapshot as
vault.login the ZIP. Each job's items are included with redacted settings (exclusion paths, container ID, mount info, ZFS dataset, etc.) so support reports show which items were configured and how. Storage destinations include their dedup state and last health-check result. A credential-free settings snapshot reports things like which categories are enabled, whether an API key is set, and the configured staging/snapshot overrides. Disk usage is probed for/boot,/tmp,/var/local/vault, the DB directory, the staging dir, and every discovered Unraid pool. Run history is split into a structured log + extracted error messages instead of one stuffed field. Activity entries now includecategoryanddetails. A new redactor scrubs Authorization, X-API-Key, Cookie/Set-Cookie, Proxy-Authorization, AWS sigv4 Credential=, JSON/query-string credential fields, inline URL credentials, and Discord webhook tokens from the log tail before it's embedded. - Diagnostics also captures verify-run history, per-destination dedup stats, runtime metrics, pool discovery, Docker / libvirt connectivity, and scheduler state. New ZIP entries:
verify_runs.json(last 25 quick + deep runs),dedup_stats.json(per dedup destination),runtime.json(goroutines, heap, GC, uptime),pools.json(every discovered Unraid pool with its mount status),connectivity.json(Docker socket + libvirt URI probes; errors are captured so "Docker not reachable" is itself the diagnostic),scheduler.json(next-run timestamp per enabled job). - Diagnostics ZIP layout is split into per-section files instead of one fat
diagnostics.json. Support engineers can nowunzip -p bundle.zip jobs.json | jqwithout paging through unrelated sections. The top-leveldiagnostics.jsonis a one-screen overview that omits activity, runs, and the log tail.
Changed¶
SaveSnapshotnow runsPRAGMA wal_checkpoint(TRUNCATE)before invoking the SQLite Online Backup API so the resulting snapshot file is self-contained and the WAL is reclaimed on disk. Checkpoint failures are logged but never block the snapshot. NewPRAGMA journal_size_limit = 67108864(64 MiB) at everyOpen()bounds the WAL between checkpoints.restoreWithFallbackrunsPRAGMA integrity_checkon every successfully-restored tier and falls through to the next tier (primary → default cache → USB backup → fresh DB) when the check fails. A corrupt snapshot can no longer silently become the working database – at worst the daemon starts with a fresh DB and tells the operator to reconfigure.- HTTP API shutdown now drains the runner for up to 30 s before the existing 5 s HTTP
Shutdownwindow. A planned restart (plugin upgrade) lets the active per-file upload reach the next boundary instead of being killed mid-stream. After the drain ceiling fires, the existing context-cancel path still tears down mid-flight work. NewRunner.Drain(ctx)is the API surface. job_runs.statuscan now beskipped. Two paths produce skipped runs: a pre-flight check failing to reach the destination, and a scheduled run firing against an open breaker. The History page renders skipped rows neutrally (not red), and the deferred retry watcher picks up preflight-skipped rows for re-attempt.- Bandwidth limit no longer offered for local storage destinations. Throttling local I/O slowed backups for no operational benefit. The Storage form hides the field for local destinations and the factory ignores any stale value left in an older config. SFTP, SMB, NFS, WebDAV, and S3 continue to honour the limit.
- Restore wizard, History timeline, Logs, and Dashboard Recent Activity now show absolute timestamps as the primary label, with the relative form (
48m ago) preserved as a hover tooltip. Matches how Veeam, restic, and Time Machine present restore points (closes #81).
Fixed¶
- Editing any field on a storage destination no longer silently corrupts the saved password. The Settings → Edit Storage modal pre-fills its password input with whatever the API returned (
"••••••••"for any destination with credentials – the redaction marker). Saving the form without retyping the password posted that marker back toPUT /api/v1/storage/{id}, which the server then stored verbatim as the new password, causing every subsequent backup/test to 401.PUT /storage/{id}now runs the incoming config throughpreserveRedactedSecrets: anysensitiveConfigKeywhose value equals the"••••••••"marker is rewritten to the corresponding value from the previously-stored config before persistence. New typed credentials still overwrite as before. The fix is server-side because the trap affected the UI modal AND any other client (MCP tools, ansible automation, direct API scripts) round-tripping a partial edit. - S3 destinations no longer fail with
Head "//<bucket>./<endpoint>": unsupported protocol scheme ""when the operator pasted a bare-hostname endpoint (e.g.s3.us-east-005.backblazeb2.com, exactly as Backblaze B2's web console displays it). The adapter handed the unmodified string to the AWS SDK, whereurl.ParseleftHostempty and the virtual-host-style bucket-prefix resolver emitted a malformed URL with no scheme.NewS3Adapternow runs every endpoint throughnormalizeS3Endpoint: bare hostnames are promoted tohttps://…, schemes other thanhttp(s)are rejected at construction time, and parsed URLs with an empty host are rejected.TestConnection-style checks and previously-stored destinations are unaffected once the daemon restarts. The same constructor now alsoTrimSpacesaccess_keyandsecret_keyto defend against the related copy-paste failure where stray whitespace from the B2 console produced a 403SignatureDoesNotMatchwith no obvious cause. - WebDAV chunked uploads to Nextcloud (and other Sabre/DAV servers) no longer fail with
WriteStreamWithLength …/000000.part: 400. The WebDAV adapter wrote each chunk of a logical-file upload as<index>.partinside the.chunkssidecar directory. Nextcloud's defaultforbidden_filename_extensions/blacklisted_files_regexreserves.partand.filepartfor its own in-progress-upload mechanism and rejects external PUTs to those names with HTTP 400 Bad Request. Single-PUT uploads (small backups, < 50 MiB) were unaffected, soTestConnectionand small jobs passed while every chunked container backup failed on the first chunk. Chunk files are now written with a.datsuffix, which is not on any known WebDAV server blacklist. The fix is forward-only safe: every chunk's absolute path is recorded in the manifest, so historical restore points continue to read correctly from their original.partchunks.writeChunked's cleanup path also now removes the (empty).chunkscollection when a chunked upload aborts on the first chunk, so failures leave nothing behind on the remote. - Dedup ratio and logical-bytes stats are correct for multi-item dedup destinations. The Storage card's dedup ratio showed
0×/0 B → N(and the diagnostics bundle reported zero logical bytes) for any destination fed by a multi-item job – e.g. a containers dedup backup.DedupAggregatessummedrestore_points.size_bytesonlyWHERE manifest_id IS NOT NULL, but that column is set only for single-item jobs; multi-item jobs leave it NULL and store per-item IDs inmetadata.item_manifests, so their logical bytes were excluded entirely. The query now sums every restore point for the destination's jobs (the destination is already known to be dedup-enabled and dedup mode is immutable per destination), so the ratio reflects real savings. - GC no longer deletes live data from large container/plugin dedup backups. A container's dedup manifest references its volume file data only indirectly: each
__vol__<dest>entry points at a sub-manifest, which lists the actual data chunks. GC's reachability set (collectLiveManifestIDs, used by both the daemon'sPOST /storage/{id}/gcand thevault dedup gcCLI) collected only the top-level manifest IDs, and GC marks just the direct chunks of each live manifest – so the volume data chunks were never marked. Any pack containing only such chunks (i.e. every data-only pack of a backup larger than one 24 MiB pack) was swept, silently corrupting the backup; the next restore would fail with missing chunks. The live set is now expanded through a newengine.WalkManifestClosurethat recurses into volume sub-manifests, so every reachable chunk is marked. Folder/plugin single-level backups were unaffected; small single-pack container backups happened to survive because their data shared a pack with a manifest chunk. Confirmed with a multi-pack regression test. - Deep and quick verify now work on multi-item dedup restore points and actually re-hash container volume data. Verify keyed off
restore_points.manifest_id, which is only set for single-item dedup jobs; a multi-item job (e.g. a containers backup) stores per-item IDs undermetadata.item_manifestsand leavesmanifest_idempty, so verify fell through to the classic per-file path and failed with "no recorded file checksums in restore point metadata". Verify now detects a dedup restore point from either source, walks every item's manifest closure (including the nested volume sub-manifests), and in deep mode re-reads + re-hashes every leaf data chunk – previously even a single-item container deep verify only checked the top-level manifest's pointer chunks, not the file data. Quick mode stats the unique packs across the full closure. - Dedup (chunked) backups now honour container/folder path exclusions, matching the classic tar pipeline.
ContainerHandler.BackupChunkedignored the job'sexclude_pathsentirely: it never appliedshouldExcludeMount(so a whole-mount exclusion like/rootfswas skipped) and never mapped per-volume globs into the chunk walk. A container that bind-mounts the host root (Glances, Telegraf, Netdata:/ → /rootfs) therefore had its entire host filesystem content-chunked into the dedup repo despite a/rootfsexclusion – slow runs, a bloated repo, and unrelated host files (boot/,lib64/,mnt/cache/appdata/…) appearing in the restore-point file list. The chunked path now readsexclude_paths, skips excluded mounts wholesale (recorded as skipped volumes), and passes volume-relative patterns through to the walk.FolderHandler.BackupChunked(used directly for folder/plugin dedup jobs and for each container volume) now applies the sameshouldExcludePathfilter the tar path uses – a matching directory is pruned (SkipDir) and never traversed. Classic tar backups were already correct; only the dedup path was affected. - Running dedup backups no longer display "-1%" in the Dashboard progress list. The daemon emits
-1as an "indeterminate progress" sentinel during the streaming chunk walk (total size isn't known up front). The per-item progress bar rendered this literally as a "-1%" label and a negative-width bar. Negative percent is now treated as indeterminate: the bar pulses at full width and the numeric label shows···until a real percentage arrives. - "Include in DB backup" toggle on the Storage page now persists. Two coordinated bugs hid behind the toast. (1)
UpdateStorageDestinationdid not includebackup_database_enabledin its UPDATE clause, so the DB silently dropped the field. (2) The storage PUT handler decodes into a partial-patch struct that omitted the field, so the request body's value was thrown away before even reaching the DB. Both were corrected. retry_max_overrideandretry_delays_override(job edit) +retry_of_run_id/retry_next_at(job runs) now serialise as native JSONnull/ number / string instead of{"Valid":false,"Int64":0}wrapper objects. The override-retry input previously showed "[object Object]" with an "Invalid JSON" error because the form bound the wrapper struct directly. Backend struct fields are now pointer types (*int64,*string,*time.Time) sodatabase/sqlround-trips nullables transparently and the JSON encoder emits clean values.- Activity log details render duration as
1 min,2.0 s,7 min 10 setc. instead of raw60002ms/430s. A newformatDurationhelper in Logs.svelte handles bothduration_msandduration_secondsdetail keys. -
Retry policy delays are no longer a JSON textbox. Both Settings → Retry Policy and Job edit → Override retry policy now use a friendly editor: preset dropdown (Default 15 m / 1 h / 4 h, Aggressive 1 m / 5 m / 15 m, Patient 1 h / 6 h / 24 h, Custom) plus per-row
[number] [unit ▾]rows with + Add / × Remove. The override section adds a "Use global default" checkbox that hides the editor and emits an empty value (= NULL on save). -
S3-compatible uploads (MinIO, MEGA, IDrive E2, Backblaze B2 via S3) no longer fail with
403 SignatureDoesNotMatchwhen the job name contains spaces or other URL-reserved characters. The aws-sdk-go-v2 + custom endpoint resolver + path-style combo has a signing-vs-sending URL-encoding asymmetry that breaks every PutObject and DeleteObject for keys containing reserved characters. TestConnection (HeadBucket) keeps passing because it has no path beyond the bucket – the "test passes, every upload fails" fingerprint. The S3 adapter now sanitizes each key path segment to the RFC 3986 unreserved set before sending to the SDK. Both Write and Read use the same translation, so existing backups continue to restore and new backups produce sanitized keys that all gateways accept (closes follow-ups on #88). - S3 PutObject no longer attaches a CRC32 SigV4 trailer that breaks S3-compatible gateways. The 2026.05.02 fix set the global
RequestChecksumCalculation=WhenRequiredbut the upload-layer client hard-codes its own CRC32 default, so the trailer was attached anyway. The S3 adapter now uses the stablefeature/s3/manager.Uploader, which honours the global setting correctly. MultipartPartSizebinding (640 GB → 10 TB per-object ceiling for large streams) is preserved. - Container backups for containers that bind-mount the host root (Glances, Telegraf, Netdata, cAdvisor, node-exporter with
/→/rootfs) no longer recursively walk the host filesystem before the volume is skipped. The 2026.05.01 fix moved the exclusion check above the per-volume backup branch, but the auto-compression-downgrade pre-scan still ran on the bind-mount source first – a fullfilepath.Walkof the host root. A Test Containers job that took 37 s before regressed to 14 minutes. The exclusion check now genuinely runs first; the compression pre-scan only touches volumes that will actually be archived. Same job is back to 38 s. PUT /api/v1/storage/{id}no longer destroys the destination when the caller sends a partial body. The handler was decoding into a fresh struct and writing the whole row, so a request like{"config":"..."}blankedname,type, anddedup_enabledand orphaned every job pointing at the destination. It now loads the existing row, merges only the fields explicitly provided, and rejects attempts to change immutable fields (type,dedup_enabled) with HTTP 400.POST /api/v1/storageno longer leaks the plaintext password / S3 secret key in its 201 response.Get()redacted the config blob butCreate()echoed the request body verbatim – credentials were visible in browser devtools' Network tab during Add Storage. BothCreate()andUpdate()now re-fetch the persisted row and apply the same redaction.POST /api/v1/storagenow validates the type and config before persisting. Previously accepted arbitrary types ({"type":"bogus"}was 201-ed) and empty configs that could never construct a working adapter. BothCreate()andUpdate()now build the adapter up-front and return HTTP 400 on construction failure with the underlying error.POST /api/v1/jobsandPUT /api/v1/jobs/{id}now include the persisted items in their response so callers can chain on the server-assigned item IDs. Job fields stay at the top level for backwards compatibility (result.idstill works).GET /api/v1/storage/{id}/jobsreturns the list of dependent jobs, not just a scalar count. Response now includes ajobsarray of{id, name}so the UI can show which jobs would be orphaned by a delete.job_countis preserved.GET /api/v1/storage/{id}/dedup-statsno longer returns 500 on a freshly-created dedup destination that hasn't run a backup yet. The Storage card now renders "0 chunks · 0 packs" instead of a broken-card state until real stats start flowing.GET /api/v1/jobs/{id}/restore-points/{rpid}/contents?item=…now works on dedup restore points instead of returning 500. The handler previously expected a per-item tar archive directory; dedup destinations don't have one. It now synthesizes the file list from the dedup manifest's per-file metadata so the restore-wizard file picker works for both classic and dedup restore points.- Recovery page no longer flags items as Unprotected when their owning job is disabled. The recovery API correctly reports
has_restore_point: trueregardless of the parent job'senabledflag, but the Svelte page derived its own protected set from enabled jobs only, so disabling a schedule (a common manual-only workflow) made items appear unprotected even though their backups were intact. The page now derives protection fromhas_restore_pointdirectly, matching the API. The Dashboard's matching logic was updated the same way. - Recovery plan step status now considers every item, not just the first. The handler previously checked only
items[0]to badge a step "ready" or "warning"; a step with one missing restore point but several intact ones was shown as ready. It now downgrades to "warning" if any item is missing. - Restore wizard file picker no longer hangs on "Loading…" after expanding "Restore specific files". The per-item state was held in a
SvelteMapwhose value objects were mutated directly after anawaitboundary – SvelteMap doesn't propagate mutations across awaits, so the UI never received theloading: falseflip. The picker now writes a fresh object reference on every transition. - Folder/Plugin Items wizard step no longer falsely flags every custom folder as "Not found". The stale-detection check compared selected items against
/api/v1/folders, which only returns engine-discoverable presets like the Flash Drive – so any user-added custom folder appeared "not found" even when the path was perfectly valid. A newGET /api/v1/path-exists?path=…endpoint does a safepath-validatedos.Stat; the picker now async-checks every custom folder's path on mount and only flags it when the stat actually fails. The empty-state message was also rewritten to point users at the "Add Custom Folder" workflow instead of looking like an error. - Dedup stats endpoint now reports correct totals across daemon restarts and from any goroutine.
Stats()previously returned in-memory counters that only the writing process saw, so the Storage card showed zeros after a restart. It now reads totals from SQL aggregates and derives logical bytes fromrestore_points.size_bytessummed over the destination's live dedup restore points. GET /api/v1/jobs/{id}/historyand/restore-pointsreturn[](not JSONnull) for jobs with no rows. Front-end code that called.lengthor.map()on the response was throwing.GET /api/v1/activity?limit=Nclampslimitto ≤ 1000 to prevent an authenticated caller (or misconfigured monitor) from OOM-ing the daemon by requesting a huge limit.- WebSocket origin patterns now cover the entire RFC 1918
172.16.0.0/12range (172.16.x.x through 172.31.x.x). Previously only172.16.*.*was listed, so users on172.17.0.0/16(Docker's default network) through172.31.x.xnever received progress updates. - Password inputs across Settings, Storage, Restore wizard, and Replication forms now declare appropriate
autocompleteattributes so browsers stop offering inappropriate suggestions and the verbose DOM warning on focus goes away. - Failed backup jobs no longer leave orphaned partial uploads at the destination. When the upload retry loop exhausted (or hit a verify mismatch or context cancellation), the runner returned without cleaning up the per-item files it had already written, wasting remote quota and confusing the next run. It now best-effort deletes every partial file before surfacing the error.
- WebDAV chunk uploads use exponential backoff with HTTP-status-aware retry classification. Chunk PUTs retry on
423 Locked,409 Conflict,429 Too Many Requests, any5xx, stall-watchdog timeouts, and transport errors (100ms → 300ms → 1s → 3s → 8s, 6 attempts). Permanent4xxfailures (401,403,404,413, …) fail fast on the first attempt instead of consuming the full retry budget. - WebDAV uploads no longer buffer non-seekable backup streams into memory or leave encryption goroutines alive after stalled retries. Bounded objects use
WriteStreamWithLengthso the fallback that copies unknown-length streams into RAM is avoided (closes #96). - Large WebDAV backups are split into manifest-backed chunks instead of one monolithic PUT. Files larger than the configured WebDAV chunk size upload as independent fixed-size objects with per-chunk SHA-256 hashes and a manifest written last. Restore, verify, list, stat, and delete are manifest-aware while older single-file WebDAV backups remain readable (closes #97).
- Importing dedup backups from another Vault instance now produces restorable restore points.
manifest.jsonwritten to storage didn't include per-item dedup manifest IDs, and the import path didn't read them either – so re-discovered dedup backups appeared in the UI but the restore would fail because the chunks were unreachable.writeManifestnow embedsitem_manifestsfor dedup runs, andImportBackupsreads the field back, persisting per-item IDs in the restore-point metadata and promoting the ID onto the row directly for single-item dedup jobs. Non-dedup imports are unchanged. Manifests written before this change still import as classic file-based restore points (no regression). - Dead code cleanup:
Server.Start,SettingsHandler.RestorationInfo,Client.GetJob,Syncer.SetHub,parseDomainDisks/domainDiskPaths, anddecompressReader/fileCompressionExtremoved. Each was either superseded by a context-aware variant, replaced by a content-based equivalent, or had no callers in the live tree. - Daemon now flushes the DB snapshot to USB flash after every config mutation, not just on startup/shutdown.
Server.SetConfigChangeHookstored the hook on the Server struct but never forwarded it to the per-handleronConfigChangefields, so everynotifyConfigChange()call across the four CRUD handlers was a no-op. The Server now stashes references to all four handlers and propagates the hook.StorageHandlerandReplicationHandlergained the missing hook plumbing too – every Create/Update/Delete now triggers the flush.
Security¶
- Folder dedup restore now validates every manifest path against the destination root before writing.
FolderHandler.RestoreChunkedjoined every manifest entry todestPathwithfilepath.Join, which does not prevent..segments from escaping the target directory. A tampered manifest at the storage destination could therefore have caused the restore to write files outside the chosen recovery folder (CWE-22 / CodeQLgo/path-injection). Every entry now goes throughsafepath.JoinUnderBase, which rejects any path that wouldn't stay insidedestPath. Closes CodeQL alerts #32–#35. - Pack writer now bounds its pre-allocation size. The pack-flush path allocated a buffer sized by
bufLen + footerLen + 4without an explicit cap. In practice these values are bounded byPackTargetSize(24 MiB) plus one chunk's slop, but CodeQLgo/allocation-size-overflowcouldn't see that. An explicit ceiling (PackTargetSize * 4) is now enforced with a clear error if exceeded. Closes CodeQL alert #37. - Discord-webhook log redaction now anchors on the
https://scheme prefix so it only matches genuine webhook URLs, not arbitrary log text that happens to mention the substringdiscord.com/api/webhooks/…. Behavioural impact is nil for real logs (the existing test still passes) but the regex now satisfies CodeQLgo/regex/missing-regexp-anchor. Closes CodeQL alert #36.
[2026.05.02] - 2026-05-15¶
Fixed¶
- S3 / Backblaze B2 uploads of multi-GB objects no longer abort with
context deadline exceededmid-multipart (closes #95). The S3 adapter previously routed every operation – including large multipart uploads viatransfermanager.Uploader– through a single hardcoded 5-minute context (ctxOp). A real Immich-style folder backup to Backblaze B2 exceeded that ceiling well before the upload (and its in-flightAbortMultipartUploadcleanup) could complete, producing a cascading context-cancellation failure. Upload operations are now scoped to a separate, configurable deadline (uploadTimeout, default 240 minutes / 4 hours) while metadata operations (Read / List / Stat / Delete / TestConnection) continue to use the existing 5-minute budget unchanged. A new optionalupload_timeout_minutesfield on the S3 storage config – surfaced in the Storage form as "Upload timeout (minutes)" under the existing advanced options – lets users tune the ceiling for very large files over slow links (set0to accept the 4-hour default; negative values are rejected at construction time). - Restored Docker containers now keep their port mappings – Unraid's "Container Port" and "LAN IP:Port" columns no longer go blank after a restore. Container backups capture
Config.ExposedPortsandHostConfig.PortBindingsfromdocker inspect, but the restore path declared a localportBindingsmap and immediately discarded it (_ = portBindings // avoid unused variable) without ever populating it from the inspect data or assigning it to the newcontainer.HostConfig. Restored containers were therefore created with zero exposed ports and zero host-port bindings, which is exactly what Unraid's Docker page renders as a blank port column. The user could "fix" the row by clicking Edit → Done because Unraid then rebuilds the container from the on-disk template XML, masking the bug. The restore engine now converts both maps into the typednetwork.PortSetandnetwork.PortMapthe Docker API requires (parsing keys withnetwork.ParsePortandnetip.ParseAddr, skipping malformed entries with a log line), and assigns them tocontainerConfig.ExposedPortsandhostConfig.PortBindingsrespectively. Empty/invalidHostIPvalues are left as the zeronetip.Addrso Docker treats them as "bind on all interfaces", matching its own default. Restored containers now show their port mappings immediately, with no Edit→Done dance required. - Restored Docker containers now show their correct update status in the Unraid Docker Manager UI instead of "not available". Unraid computes a container's "up-to-date" / "update available" badge by reading
RepoDigests[0]from each image's metadata, butdocker load(used during restore) does not populateRepoDigests– onlydocker pulldoes. As a result, restored containers showed "not available" and the "Check for Updates" button could not resolve a local digest, leaving the badge stuck. The container engine now captures each image'sRepoDigestsat backup time into a smallimage_meta.jsoncompanion file (alongsideimage.tar*), and the restore path uses that to re-seed/var/lib/docker/unraid-update-status.jsonwith the recorded local digest afterImageLoad. The Unraid UI then resolves "up-to-date" or "update available" immediately, and "Check for Updates" works against the restored container. Verified end-to-end on Unraid against the "Test Containers" job: Grafana, Influxdb, and telegraf all flipped from "not available" to "up-to-date" after a fresh backup→restore cycle. - Restore now also performs a
docker pullafterImageLoadto authoritatively populateRepoDigestsfor the restored image. This handles the chicken-and-egg case where the source container's image itself had noRepoDigestsat backup time (e.g. the image was previously restored from tar without ever being pulled, or the user is upgrading from a Vault version older than theimage_meta.jsoncapture), in which case the backup'simage_meta.jsonrecordedrepo_digests: nulland the priorunraid-update-status.jsonseeder had nothing to write. The pull is non-destructive – when layers already exist locally (the common case immediately afterImageLoad) Docker only fetches the manifest, which is exactly the metadata required. The freshly-pulled digest is then written directly intounraid-update-status.json, so the badge flips to "up-to-date" without depending on the backup's recorded metadata. If the daemon is offline or the registry is unreachable, the call fails silently and the path falls back toimage_meta.json-based seeding. Verified end-to-end on Unraid:nicolargo/glances:latest(which had emptyRepoDigestson the host from a prior tar-only restore) flipped from "not available" to "up-to-date" after restore-with-pull. - Container Path Exclusions now skip directory bind mounts at the volume level (closes #70). The earlier fix (2026.05.01) only applied exclusion patterns to single-file bind mounts. Directory mounts – most notably the recursive
/→/rootfsmount used by Telegraf, Netdata, cAdvisor, Glances, and Prometheus node-exporter – still entered the recursive walk and only filtered special-file inodes, so the engine eventually started archiving the host filesystem (including the array into itself) and the job hung indefinitely. Exclusion matching now runs once, up front, against every bind mount's container-side destination regardless of whether the mount is a file or a directory, mirroring the single-file behaviour the user reported as working. Verified end-to-end on Unraid with a real "Test Containers" job (Influxdb + Grafana + Glances + Telegraf, both Glances and Telegraf bind-mounting/→/rootfs): job completed in 37 s (4/4 items, 1.9 GB) withengine: skipping volume / … matches exclusion patternin the daemon log, replacing a previous 13 m 37 s hang that failed at 2/4 items. - Compression setting now controls the actual on-disk archive format. Previously the engine always emitted gzip-wrapped tar archives while the runner additionally wrapped the upload stream with whatever compression the user selected, so jobs configured for
zstdshipped double-compressedvolume_*.tar.gz.zstfiles and jobs configured forNonestill produced.tar.gz. The engine (internal/engine/container.go,folder.go,plugin.go) is now the single source of truth for archive compression and emits.tar,.tar.gz, or.tar.zstperBackupItem.Compression, while the runner's upload path no longer wraps the stream a second time. Restore auto-detects the format via magic bytes (0x1f 0x8bfor gzip,0x28 0xb5 0x2f 0xfdfor zstd, plain otherwise) and the engine looks up archives by tryingbase,base+".gz", andbase+".zst"in turn, so existing.tar.gzbackups (and legacy double-wrapped.tar.gz.zstfiles via the runner-sidedecompressStoredReader) continue to restore correctly. Verified end-to-end on Unraid against the "Test Containers" job:none→.tar,gzip→.tar.gz,zstd→.tar.zst. - Restore wizard "Choose Version" step now shows the absolute date alongside the relative offset inline (closes #81 follow-up from @SebboGit). The previous fix relied on a native
titletooltip to surface the relative time on hover, but native browser tooltips were unreliable on the parent button row (Firefox in particular suppresses them when the<span>lives inside arole="button"/cursor: pointerancestor). Both forms are now rendered together as12 May 2026, 02:09 am · 7h ago, giving an unambiguous absolute timestamp with the relative offset always visible – no hover required, works on touch. - Logs page no longer collapses expanded "Details" rows every 5 s during auto-refresh.
loadLogs()previously calledexpandedIds.clear()on every poll cycle and re-added only error rows, so any non-error row a user had manually expanded was wiped on the next refresh. The expanded set is now preserved across reloads, entries that have aged out of the visible window are pruned, and the auto-expand-error behaviour now tracks a separate "already auto-expanded" set so collapsing an auto-expanded error keeps it collapsed. - Failed backup jobs no longer leave orphaned partial uploads at the storage destination. When
uploadStagedFilesexhausted its 4-attempt retry loop (or hit a verify mismatch or ctx cancellation), it returned an error without cleaning up the per-item files it had already successfully uploaded, or the partially-written bytes from the failing item. This wasted remote-storage quota (especially painful on quota-bounded providers like Hetzner Storage Box) and made the destination directory look "half done" to the next run. The runner now performs best-effortadapter.Delete()on every known partial file before returning the error. Cleanup failures are logged but never mask the original upload error. -
WebDAV uploads of multi-GB files no longer fail with
context deadline exceeded ... Client.Timeout exceeded while awaiting headers(closes #83 follow-up from @SebboGit). The WebDAV adapter previously calledgowebdav.Client.SetTimeout(60*time.Second), which maps tohttp.Client.Timeout– a single deadline that covers the entire request lifetime, including the upload body. A 20-container backup to Hetzner Storage Box repeatedly failed during the upload-retry loop (4× exponential-backoff attempts per item, all timing out at the same 60 s mark), eventually crashing the daemon. The fixed 60 s deadline is replaced with phase-specifichttp.Transporttimeouts that do not bound body-transfer duration: -
TCP dial 30 s, TCP keepalive 30 s
- TLS handshake 30 s
- Expect-Continue 5 s
- Response headers 5 min (applies AFTER the body is fully sent)
- Idle keep-alive 90 s
http.Client.Timeoutleft at 0 (unlimited) by default
In addition, every upload reader is now wrapped in a stall watchdog: it records the timestamp of every successful Read() and aborts the PUT with webdav: upload stalled when no bytes flow for the configured window (default 300 s / 5 min). Slow-but-progressing transfers of any size succeed; truly dead connections still fail promptly without depending on the OS keepalive.
- Restore no longer fails with
invalid input: magic number mismatchfor backups produced by the new engine-side compression path. After the compression refactor that made the engine the single source of truth for archive compression, the runner stopped transport-wrapping uploads but the restore path'sdecompressStoredReaderstill blindly appliedjob.Compressionto every downloaded file. That caused plain artifacts likeconfig.jsonto be force-fed into a zstd reader during the restore of any zstd-mode job, producingdownloading Test Containers/.../Glances/config.json: invalid input: magic number mismatchand aborting the run at 0/N items.decompressStoredReaderis now content-based – it peeks the first four bytes and only strips a layer when the leading bytes actually match the gzip (0x1f 0x8b) or zstd (0x28 0xb5 0x2f 0xfd) magic, ignoring both the filename extension and the job's compression setting. This still strips one transport layer from legacy double-wrapped backups while leaving newly produced plain files (config.json,manifest.json,image.tar) untouched. Verified end-to-end on Unraid: a fresh zstd backup of 4 containers (Influxdb + Grafana + Glances + telegraf, 575 MB) restored in 1 s with 4/4 items succeeded, replacing the previous 1-failed/0-done error.
Added¶
- Image metadata capture (
image_meta.json) is now written next to each container'simage.tar*during backup. Storesimage_tagandrepo_digestsso the engine can repopulate Unraid's dockerunraid-update-status.jsonon restore (see Fixed). Old backups without this file fall back to current behaviour silently; the file is best-effort and never fails a backup. - Backup Size Trend chart on the History page is now coloured by backup target category. Each run is rendered as a bar tinted by its dominant item type – Containers (blue), VMs (purple), Folders & Files (green), Flash (amber), Other (gray) – with a top-right legend that doubles as a category filter. The hover tooltip shows the category swatch alongside the run's size, date, and job name, making it easy to see at a glance whether a size spike came from a container-heavy run, a VM snapshot, or a folder backup. The linear-regression trend indicator (Growing / Stable / Shrinking) is preserved. Job categories are derived from the dominant
item_typeacross each job's items, hydrated via parallelgetJobcalls when the History page loads. - Helper text under the Compression dropdown in the Job wizard. Each option now surfaces a one-line trade-off summary so users can choose deliberately:
None(fastest, zero CPU overhead, best for already-compressed media),Gzip(universal compatibility, moderate ratio/speed),Zstd(recommended; better compression than gzip and ~3–5× faster). - Two new optional WebDAV config fields exposed in the Storage form under "Advanced · Timeouts":
timeout_seconds– overall hard ceiling on every WebDAV request including upload body. Default 0 = unlimited (recommended). Power users can set a value if they want a strict per-request cap.stall_timeout_seconds– abort an upload if no bytes flow for this many seconds. Default 300 (5 min). Set to a negative value to disable the watchdog entirely.- Recommended-exclusion presets for host-mount monitoring agents and Docker-socket-consuming management containers. Adds curated
ContainerExclusionPresetsentries for Telegraf, Glances, Netdata, cAdvisor, Prometheus node-exporter, Scrutiny, Prometheus, Loki, VictoriaMetrics, Uptime Kuma, Watchtower, Diun, Dozzle, Autoheal, Dockhand, and docker-socket-proxy, and enhances the existing Portainer preset to include/var/run/docker.sock. Telegraf and Glances now ship with/rootfs//hostfs//var/run/docker.sock(and friends) so the "Load recommended exclusions" button on the Container Path Exclusions step auto-populates the exact paths needed to prevent the recursive host-filesystem walk described in #70. The existing Svelte UI flow (fetchContainerPresets()→GET /api/v1/presets/exclusions?image=...) picks up the new presets automatically – no UI change required. Verified end-to-end on Unraid: clicking the button on the Test Containers job's Glances item populated/rootfs,/var/run/docker.sock,/etc/os-release(3 paths) and on the telegraf item populated/rootfs,/hostfs,/var/run/docker.sock,/var/run/utmp,/run/udev(5 paths), with both buttons flipping to the disabled "Recommended exclusions loaded" state. /var/run/docker.sockand any other socket bind mounts are now auto-suggested as exclusions even when no image preset matches. The presets API (GET /api/v1/presets/exclusions) now accepts an optionalcontainer=<name_or_id>query parameter alongsideimage=. When provided, the daemon inspects the live container via the Docker SDK and appends every bind-mount destination whose host source ends in.sock(e.g./var/run/docker.sock,/var/run/docker-shim.sock) to the recommended exclusions, deduplicated against the static image preset list. The job editor's "Container Path Exclusions" panel now passes the container name, so any container mounting a runtime socket gets the exclusion auto-populated without anyone having to maintain a per-image preset entry. Inspect failures are silently ignored so the editor still falls back to the static image preset list.
Changed¶
- Renamed the internal helper
shouldExcludeFileMounttoshouldExcludeMountand hoisted the call above theIsDir()dispatch inContainerHandler.Backup()so it covers both directory and file mounts.
Security¶
- Bumped Go toolchain to 1.26.3 and
golang.org/x/netto v0.53.0 to address twogovulncheckfindings.GO-2026-4971(net: panic inDial/LookupPorton Windows when handling NUL bytes – stdlib, fixed in Go 1.26.3) andGO-2026-4918(HTTP/2 transport infinite loop on badSETTINGS_MAX_FRAME_SIZE– both stdlib andgolang.org/x/net, fixed in Go 1.26.3 /x/netv0.53.0).make security-check(gosec + govulncheck +go mod verify) is now clean: 0 gosec issues, 0 vulnerabilities, all modules verified.
[2026.05.01] - 2026-05-10¶
Fixed¶
- VM checkpoint creation now succeeds for cold (shut-off) backups. Previously the engine called
DomainCheckpointCreateXMLagainst the persistent domain handle afterrestoreDomainState()had already destroyed the transient paused session – libvirt rejected the call with "Operation not supported: cannot create checkpoint for inactive domain", so every cold incremental/differential ran without ever recording a parent checkpoint and silently fell back to producing a full-size qcow2. Checkpoints are now created against the live (paused)backupDomwhile the transient backup session is still active, and only after the bitmap is persisted to qcow2 does the runner tear the session down. End-to-end verified against a cold Fedora VM: full = 2.3 GB, incremental and differential = 192 KB on a no-change cycle. - Engine no longer reports "incremental" / "differential" when no parent checkpoint is recorded. When the runner had no checkpoint metadata to pass (first run after a chain reset), the engine still produced a full-size backup but labelled the result
vm_backup_type: incremental, which corrupted the chain bookkeeping. The fall-back logic now also covers the empty-parent case and downgradesactualTypeto"full"with a clear log entry before writingvm_meta.jsonandresult.Meta. - VM incremental backups now actually capture only changed blocks. Previously every "incremental" or "differential" run silently produced a full copy of each qcow2 disk: the engine emitted a regular libvirt push-mode backup XML with no
<incremental>element, and the only "incremental" behaviour was a file-mtime gate that either (a) re-copied the whole disk anyway when the VM was running (mtime always advanced) or (b) skipped the disk entirely when the VM was cold (mtime unchanged after the previous backup) – silently losing data. The engine now creates a libvirt checkpoint (persistent dirty bitmap stored in qcow2) after each successful backup and references it via<incremental>parent_checkpoint</incremental>on subsequent runs, so libvirt streams only the dirty blocks since the parent checkpoint. Restore points record the checkpoint name invm_checkpointsmetadata; missing-parent or non-qcow2 disks fall back to a full backup with a clear log entry. Retention cleanup deletes orphaned libvirt checkpoints. (closes the broken VM Incremental / Differential modes) - Backups failing with
No space left on devicewhen staging to/mnt/addons(1 MB tmpfs). The pool-discovery cascade ininternal/unraid/pools.goselected any directory that exists under/mnt/, which on modern Unraid includes the/mnt/addonstmpfs mount. Pool discovery now reads/proc/self/mountinfoand rejects paths backed bytmpfs,devtmpfs,ramfs,proc,sysfs,cgroup,cgroup2, oroverlayfilesystems, so staging falls through to a real cache or array pool.
Added¶
- VM chain restore for incremental and differential backups. The runner now stages each chain step into its own subdirectory and uses
qemu-img rebase -uplusqemu-img convert -O qcow2to materialise a single self-contained qcow2 per disk before invoking the existing libvirt restore path. The qemu-img invocation lives in the runner package to preserve the engine package's pure-Go invariant. BackupResult.Metachannel for engine-specific metadata. The VM engine populates it withvm_checkpoint,vm_backup_type, and (when applicable)vm_parent_checkpoint, which the runner persists into restore-point metadata so future backups can locate their parent checkpoint and retention can clean up libvirt-side state.- VM
vm_meta.jsonsidecar now recordsBackupType,Checkpoint,ParentCheckpoint, and per-diskDisks[]entries (Target,BackupFile,Format) for diagnostics and forward compatibility.
Changed¶
- Removed the legacy mtime-based
changed_sincegate from the VM backup path. Folder and container backups continue to use it. ZFS keeps its dataset-snapshot-based incremental flow unchanged.
Verified¶
- Hot-VM backups end-to-end on a running Fedora guest: Full = 2.34 GB / 34 s (
vault-run-6), Incremental from prior run = 3.5 MB / 1 s (vault-run-7), Differential from last full = 4.07 MB / 1 s (vault-run-8). All three runs created persistent libvirt checkpoints on the live (paused) backup session. - VM chain restore end-to-end: restored Fedora from incremental RP 7 (parent = full RP 6) using
qemu-img rebase -u+qemu-img convert. The runner staged both layers, materialised a single 2.18 GiB self-contained qcow2, the engine redefined the domain and auto-started it, and the guest kernel booted successfully (balloon driver active, vCPUs accumulating CPU time).
[2026.05.00] - 2026-05-10¶
Added¶
- Defer remote upload mode for slow-link backups (closes #77). New per-job toggle "Defer remote upload until all backups complete" stages every container backup to local disk first, restarts all containers as soon as staging is done, then streams the staged archives to the remote destination in a second phase. Containers go offline only for the (fast) local backup window, while the (slow) remote upload runs while every service is back online. The toggle is disabled when the destination is local (no benefit) and is automatically gated behind a non-local
dest.Type. Per-file uploads are now retried up to 3 times with exponential backoff (5s → 30s → 2m), recovering automatically from transient remote-storage failures that previously failed entire jobs. WebSocketbackup_phase,item_staged, anditem_upload_startevents drive a STAGING / UPLOADING phase indicator in the Backup Progress panel. Schema migration adds adefer_remote_uploadcolumn tojobs(default off). v1 scope: containers only – VM/folder/plugin/zfs items continue to upload inline. - WebDAV storage backend (closes #83). Stateless HTTP-based backup target that avoids per-user concurrent-connection limits seen on managed SFTP/SMB providers. Supports Basic and Digest auth (negotiated automatically), optional self-signed TLS, and an optional base path. Compatible with Nextcloud, ownCloud, Synology WebDAV, and any RFC 4918 server.
- S3 / S3-compatible storage backend (closes #88). Built on AWS SDK v2 with a reusable, connection-pooled client. Works with AWS S3, Backblaze B2, MinIO, Cloudflare R2, and Wasabi via configurable endpoint and force-path-style options. Uses the SDK's manager.Uploader for streaming uploads of arbitrarily large archives without buffering to disk.
Changed¶
- Restore wizard "Choose Version" step, the Restore page restore-point list, the Job History timeline, the Dashboard Recent Activity panel, and the Logs page now show absolute timestamps (e.g.
Apr 21, 2026, 21:22) as the primary label on each row, with the relative form (48m ago,2d ago) preserved as a hover tooltip. This matches how Veeam, restic, and Time Machine present restore points and aligns with established UX guidance: when the user is matching a backup to a remembered point in time ("this was working fine until 17 April"), an absolute date is far easier to scan than mentally subtracting a relative offset (closes #81)
Fixed¶
- S3 storage adapter requests now correctly include the bucket name in the URL when a custom endpoint is configured (e.g., MinIO, Backblaze B2, Cloudflare R2, Wasabi). The custom
EndpointResolverV2previously returned the bare endpoint URI, which suppressed the AWS SDK v2's automatic bucket injection – every PutObject / GetObject / HeadBucket request landed on the root path and the server responded withNoSuchBucket. The resolver now injects the bucket into the URL path (path-style) or hostname (virtual-host style) itself, matching what the SDK does for the default AWS resolver. Confirmed end-to-end against MinIO via the newmake test-cloud-storagesmoke test (closes #88) - Add Storage modal: switching between storage types (SFTP/SMB/NFS → Local Path, etc.) now correctly tears down the previous type's fields. Previously, fields from the prior selection could persist after a type change due to Svelte 5 reusing DOM input nodes across the dynamic
{#if}branches. Wrapped the dynamic config block in{#key form.type}so the entire field subtree is recreated on every type switch - Pool drive detection no longer falsely reports "No cache drive detected" when
/mnt/cacheexists as an unmounted directory or when the user's pool is named something other thancache.unraid.PreferredPool()now consults/proc/self/mountinfo(via the existingIsMountedPool()helper) and returns the first mounted pool from discovery instead of returning/mnt/cachewhenever the directory is present.checkCacheMount()likewise usesIsMountedPool()rather than the previous "directory non-empty = mounted" heuristic, so empty-but-mounted pools are correctly recognised as available for hybrid mode. Daemon startup also now logs every discovered pool with its mount status to make this diagnosable from/var/log/syslog(closes #69) - New / Edit Job wizard no longer freezes on the second step when accessed over plain HTTP (e.g.
http://<server>:81/plugins/vault/ui/...). The Tooltip component calledcrypto.randomUUID()unconditionally, but that API is only available in secure contexts (HTTPS orlocalhost); on plain HTTP, Firefox throwsTypeError: crypto.randomUUID is not a function, which crashed every wizard step that mounts new tooltips (Schedule / Details / Advanced) while the stepper indicator still advanced. Tooltip now generates its fallback id with aMath.random()-based path whencrypto.randomUUIDis unavailable. The same fix also restores the SMB/CIFS Username/Password fields and the Settings page, which previously failed to render in Brave / Edge / Firefox over plain HTTP for the same reason. Closes #67, closes #82 - Importing backups from a storage destination now restores the full job and per-item configuration, not just the job name and encryption type. The on-disk
manifest.jsonis now versioned (v2) and persists per-item rows (name,type,id,settings) plus job-level settings (backup_type_chain,retention_count,retention_days,container_mode,vm_mode,notify_on,verify_backup). The Storage → Import flow uses these fields to recreate Vault'sjob_itemsrows so the restore wizard immediately lists each backed-up container/VM/folder. For older manifests without anitemsarray, item names are inferred fromitem_sizes; appdata.backup imports create a single container item per file. Closes #75 - Restore points, Dashboard Protection Status, and the Disaster Recovery plan no longer disappear when a job's schedule is disabled. Protection is now derived from whether items have actual restore points on disk, not from the
enabledschedule flag – so disabling a schedule (e.g. for a one-off / manual-only backup workflow) keeps already-backed-up items visible and protected. AffectsGET /api/v1/health/summary(protected_items/protection_pct),GET /api/v1/recovery/plan, and the Restore page job picker (closes #76) - Container backups no longer fail with
archive/tar: sockets not supportedwhen a container has a Unix domain socket bind-mounted (e.g. Dockhand mounting/var/run/docker.sock). The engine now (a) auto-skips bind mounts whose source is a non-regular inode (socket, named pipe, device, irregular) with a clearunsupported inode typeskip reason, and (b) honours user-defined Container Path Exclusions for file-based bind mounts using the same pattern semantics as directory mounts – exact paths, parent directories, basenames, and globs (*docker.sock*,*.sock) all work as documented in the UI hint. Previously the file-mount branch ignored the exclusion list entirely (closes #70) - Stop and Restart buttons on Settings → Vault no longer fail with
Vault service control failed (HTTP 403). The plugin'scontrol.phpwas performing a redundant CSRF check, but Unraid's web framework already validatescsrf_tokenat the gateway and strips it from$_POSTbefore forwarding to PHP – so the in-script comparison always saw an empty token and returned 403. The redundant check has been removed; the gateway CSRF validation plus the POST-method requirement still protect the endpoint (closes #71) - Eliminated the dual-scrollbar layout bug on the Vault web UI Settings page (and any other tall page).
html,body, and#appare now locked to the viewport height withoverflow: hidden, so the SPA's<main>is the only scroll surface – the page's left navigation can no longer scroll out of view (closes #73) - Service Control on the Vault settings page no longer falsely reports "STOPPED" when a Stop/Restart action fails. The Stop and Restart buttons now use a conservative fallback (preserve the pre-action state on errors), surface an inline error message when the request fails or the rc script exits non-zero, and confirm the real outcome by polling the daemon after the action. The PHP
is_running()helper now consults/api/v1/health(the same authoritative check used at page load) and only falls back to the PID file when the health endpoint is unreachable, so post-action status agrees with what the user sees on refresh (closes #71) - Plugin upgrades now stop the running Vault daemon before
upgradepkg --install-newand restart it afterwards, so users get the new binary without needing to reboot Unraid. User configuration (vault.cfg), the database (vault.db), and sealed credentials under/boot/config/plugins/vault/are preserved across upgrades (closes #72) - Settings and configuration are no longer lost on plugin upgrade. In hybrid mode the daemon now refreshes the USB safety-net (
/boot/config/plugins/vault/vault.db.backup) immediately after a successful restoration at startup, so the recovery chain (configured snapshot → default cache snapshot → USB backup) always has a current entry – even if a previous daemon shutdown was unclean. Combined with the graceful pre-upgrade stop, this guarantees a fresh-database fallback can no longer happen silently during an upgrade (closes #74) - Storage adapter durability:
LocalAdapter,SFTPAdapter, andSMBAdapterWrite()now perform a checkedSync()(where supported) plus a checkedClose()and remove the partial file on any error, instead of relying on adefer f.Close()whose error was swallowed. Previously a write that failed mid-stream – or a finalClose()that returned an error from a buffered remote SMB/SFTP server – would leave a truncated file on the destination and report success to the caller. - Database restore endpoint hardening:
POST /api/v1/storage/{id}/restore-dbnow validatesstorage_path, rejecting empty values and any payload containing... The handler also propagates errors from closing the live DB, the source temp file, and the destination DB file – and nowSync()s the destination before declaring success, removing the partial file on any failure. Previously these errors were silently discarded. - Health score rounding:
GET /api/v1/health/summarynow rounds the weighted health score to the nearest integer instead of truncating, so a 99.6% score displays as 100 rather than 99. - Job history input validation:
GET /api/v1/jobs/{id}/historynow returns HTTP 400 whenlimitis non-numeric or less than 1 instead of silently falling back to the default of 50. - Frontend listener registration: the hash-based router and theme module now guard against duplicate
hashchangeandprefers-color-schemelisteners under hot module reload, eliminating dev-mode listener leaks. - SFTP port input validation: the Storage modal
portfield now enforcesmin=1/max=65535, preventing accidental zero or negative values being stored. - Command palette error visibility:
CommandPalette.sveltenow logslistJobs/listStorage/runJob/testStoragefailures viaconsole.errorinstead of swallowing them silently, making palette malfunctions diagnosable from devtools. - S3 List timeout consistency:
S3Adapter.List()now uses the sharedctxOp()helper instead of a divergent hardcoded 10-minute timeout, matching every other S3 operation. - Request logger guidance: added an explicit comment in
internal/api/middleware.goenumerating which authorization headers must be redacted if header logging is ever added (Authorization,X-API-Key,Cookie,Set-Cookie,Proxy-Authorization), to prevent accidental credential leaks in future changes. - Emergency-kit download race:
Settings.sveltenow defersURL.revokeObjectURL()for one second aftera.click()so slow disks/browsers don't lose the blob URL before the download starts. - Missing dynamix.cfg log warning:
detectTimeFormatFromPath()now logs a warning when reading/boot/config/plugins/dynamix/dynamix.cfgreturns an unexpected error (anything other than "file not found"), so silent fallbacks toautoare no longer invisible.
[2026.04.00] - 2026-04-16¶
Added¶
- "Apply" button on the Temporary Work Area custom location input for consistency with the Database Location pattern
- GitHub Sponsors link in the About Vault section on the Settings page
- CORS middleware (
go-chi/cors) restricting cross-origin requests to*.myunraid.net,localhost, and127.0.0.1origins only (OWASP A01) - IP-based rate limiting (
go-chi/httprate) on auth-sensitive endpoints: encryption verify (10 req/min), API key generate/rotate (5 req/min) (OWASP A05/A07) - Auto-seal migration on daemon startup: legacy plaintext
encryption_passphrasevalues are automatically sealed with the server key and the plaintext is cleared (OWASP A02) Cache-Control: no-storeheader onGET /api/v1/settings/encryption/passphraseto prevent caching of sensitive passphrase responses (OWASP A02)- Remote Vault API Key field on the Add/Edit Replication Target form – allows users to enter the shared API key for authenticating with a remote Vault server during replication sync
- Replication sync and test-connection now use the configured API key (via
X-API-Keyheader) when connecting to authenticated remote Vault instances - Test Connection in the replication modal now always performs a live connectivity check against the remote Vault server, verifying the URL is reachable and the server is healthy
- API key management: generate, reveal, rotate, copy, and revoke a shared API key from Settings > Security for authenticating external integrations (Home Assistant, replication) – key is stored sealed (AES-256-GCM) and verified via bcrypt
- Settings > Security > API Access card showing key status, reveal/copy, rotate, and revoke controls with confirmation dialog
X-API-Keyheader support in the replication client for authenticated cross-server syncapi_keycolumn onreplication_sourcestable for storing per-source API keys- ZFS zpool support for database location: the path browser now includes ZFS pool mountpoints when browsing for custom database snapshot locations via
include_zfsquery parameter (closes #50) - ZFS zpool support for temporary work area: NVMe-backed ZFS zpools are automatically detected at daemon startup and prepended to the staging cascade, giving them the highest priority for backup assembly (closes #51)
ListNVMePools()method onZFSHandlerto discover zpools composed entirely of NVMe devicesListZFSMountpoints()method onZFSHandlerto enumerate all accessible ZFS dataset mountpointsPrependCachePaths()function intempdirpackage to inject high-priority staging paths at runtimeBrowseHandler.SetZFSLister()for pluggable ZFS mountpoint discovery in the browse API- Updated Settings page text to mention ZFS zpools as available locations for database and staging
internal/unraidpackage withDiscoverPools(),PreferredPool(), andIsMountedPool()for dynamic Unraid pool detection – replaces hardcoded/mnt/cachereferences across the codebase (closes #49)- Contextual tooltips across Settings, Jobs, Storage, and Replication pages – reusable
Tooltip.sveltecomponent with hover/click-to-toggle, viewport-aware positioning, keyboard dismissal, and full ARIA accessibility (closes #34) - Enriched activity logs with contextual details for troubleshooting: backup started/completed and restore completed entries now include job name, backup type, storage destination, duration, and size; per-item container health check results are logged individually under a new "health" category; stop_all health check summary includes aggregate counts (containers checked/healthy/unhealthy) (closes #30)
- "Health" category filter on the Logs page to isolate container health check entries
- Smart formatting for activity log detail badges: backup types are capitalised, durations show unit suffixes, byte sizes are human-readable (e.g. 2.2 GB), and null values are hidden
- Diagnostic bundle download:
GET /api/v1/settings/diagnosticsendpoint and "Download diagnostics bundle" button on the Settings page generates a ZIP containing system info, database details, storage destinations, job configurations, recent run history, and activity logs with a unique correlation ID for support workflows (closes #29) internal/diagnosticspackage with collector, ZIP packager, and comprehensive redaction for sensitive data (passwords, API keys, tokens, webhook secrets, inline URL credentials)ListRecentRuns(limit)database method for fetching recent job runs across all jobs- Purge activity logs:
DELETE /api/v1/activityendpoint and "Purge" button on the Logs page with confirmation dialog to permanently delete all activity log entries (closes #32) - Purge job run history:
DELETE /api/v1/historyendpoint and "Purge" button on the History page with confirmation dialog to permanently delete all job run records (closes #32) PurgeJobRuns()database method for bulk deletion of job run history; activity log purge reusesDeleteOldActivityLogs(0)to clear all entries- Job run history purge actions are logged in the activity log with the count of deleted records
- Cancel API endpoint
POST /api/v1/jobs/{id}/cancelto abort a running backup job (closes #28) - Cancellable context propagated through the entire backup pipeline: Runner → engine handlers → tar/copy I/O operations
- 4-hour job timeout with automatic cancellation via
context.WithTimeout - Stall detection: warns after 30 minutes of no progress, auto-cancels after 2 hours of inactivity
cancellingfield added to runner status for real-time UI feedbackjob_cancellingWebSocket event broadcast when cancellation is requested- "cancelled" job run status with descriptive log messages (user-initiated vs timeout)
- Context-aware
contextCopyhelper that checks for cancellation every 32 KiB during file I/O ctx.Err()checks infilepath.Walkcallbacks to abort directory traversal on cancellation- Backup target category toggles in Settings → General: independently enable/disable tracking for Containers, Virtual Machines, and Flash Drive; disabled categories are excluded from protection status on the Dashboard and readiness metrics on the Recovery page (closes #20)
- Three new settings keys (
container_backup_enabled,vm_backup_enabled,flash_backup_enabled) with"true"defaults in the settings API - Monthly and yearly scheduling now support "First day of month" and "Last day of month" options in the schedule builder UI; last-day jobs use a daily-check pattern on the backend with an
isLastDayOfMonth()guard so they fire correctly on months of any length (closes #15) - Unraid display time format is now detected from
dynamix.cfgand injected into the runtime config, allowing the UI to honour the user's 12-hour or 24-hour preference - Go daemon (direct-access mode) now injects
window.__VAULT_RUNTIME_CONFIG__into the SPA HTML, ensuring time format detection works when accessing Vault directly on port 24085 without the PHP proxy getTimeFormat()andgetHour12()helpers added toruntime-config.jsfor locale-aware time renderingformatDate()utility now used consistently for all date/time display in the Storage and Settings pages
Changed¶
- Storage form: added tooltips on SFTP Remote Path, SMB Share, NFS Export Path, and NFS Base Path to clarify the distinction between fields that look similar (e.g. export vs sub-path within mount)
- Replication target form: API key field is now required (marked with asterisk, form submit blocked without it); "Test Connection" is blocked if no API key is entered; warning callout directs users to generate a key on the remote server under Settings → API Access
- Removed the "Target Type" dropdown from the Replication target form – only "Remote Vault Server" is supported
- Removed the Fallback Locations collapsible section from the Temporary Work Area on the Settings page to reduce clutter
- Removed the WebSocket status row and Reconnect button from Server Information on the Settings page
- Removed the WebSocket/Polling status indicator from the sidebar footer
- Renamed "Staging Directory" section to "Temporary Work Area" with descriptive subtitle explaining its purpose (closes #13)
- Replaced "SSD Cache (automatic)" label with "Using SSD cache for fast backup processing" and "Custom override" with "Custom location"
- Renamed "Custom Path (optional)" to "Custom Location" with description: "Override the automatic location. Use this if you want backups to be assembled on a specific drive."
- Renamed "Cascade order" to "Fallback locations" with description: "Vault tries each location in order and uses the first available one."
- Updated Database Location subtitle to explain that Vault's database tracks jobs, schedules, and restore points
- Replaced "Hybrid (RAM + SSD snapshots)" with "Hybrid – runs in memory for speed, saves to SSD periodically"
- Renamed "Working" to "Active database" with tooltip explaining hybrid mode operates from RAM
- Renamed "Snapshot" to "Saved copy", "Last snapshot" to "Last saved", and "Snapshot size" to "Saved copy size"
- Renamed "Custom Snapshot Path (optional)" to "Custom save location" with description: "Choose where the persistent database copy is stored. Defaults to SSD cache."
- Enhanced USB warning to suggest adding a cache drive or setting a custom save location
- Simplified Backup Targets subtitle to "Select what Vault should monitor. Disabled items won't show as unprotected on Dashboard or Recovery."
engine.Handlerinterface now acceptscontext.Contextas the first parameter forBackup()andRestore()- All engine handlers (Container, VM, Folder, Plugin) updated to accept and propagate context
Runner.backupItem()now receives and passes context to engine handlers- Vault database backup now writes to a centralized
_vault/vault.dbpath at the storage root instead of inside each job run directory, eliminating duplicate database copies across backup jobs - Import Backups "Restore Full Database" section now shows a single "Vault Database" entry with the backup date instead of listing individual job names
Removed¶
- Removed Google Drive and OneDrive replication support – only Remote Vault Server replication is now supported
- Removed OAuth infrastructure (client credentials, build-time ldflags, callback handlers) for cloud storage providers
Fixed¶
- Fixed job creation wizard step indicator scrolling out of view on tall steps (e.g. step 3 with many schedule and storage fields) – the step indicator is now pinned in a non-scrolling band below the modal title, always visible regardless of scroll position
- Fixed path browser breadcrumbs showing double slashes (e.g.
//mntinstead of/mnt) when browsing server paths for storage and database locations - Added per-restore-point deletion: each restore point in the Restore wizard now has a trash button (two-click confirm) that deletes both the backup files from storage and the database record – closes the user request for "delete a backup without deleting the job"
- Added subfolder field to the Import Backups modal on the Storage page – lets users point the scanner at a specific subdirectory when their AppData Backup archives are not at the storage root (e.g.
appdata-backups/) - Fixed AppData Backup flash-backup detection to match any
<hostname>-<date>.zipfile instead of onlycube-*.zip– works for systems named tower, unraid, or any other hostname - Fixed Backup Size Trend chart showing wildly incorrect percentages (e.g. +1120%) when multiple jobs are visible – switched from last-point comparison to linear regression; mixed-job views now show a directional label (Growing/Shrinking/Stable) instead of a misleading number, while single-job filtered views still show the exact percentage
- Verified sequential job execution: 3 jobs queued simultaneously (Jackett, Mosquitto/Tailscale, Fedora VM) all completed in sequence without stalling – the reported "stops after 2 backups" issue does not reproduce on the current codebase
- Fixed missing
getAPIKeyStatus,generateAPIKey, andrevokeAPIKeymethods in the API client (api.js), which caused a console error on every Settings page load and always showed "No API key configured" regardless of actual state - Fixed undefined
bg-accent/text-accentTailwind v4 tokens on the "backup" type badge in History run rows – replaced withbg-vault/15 text-vaultso the badge is now visible in both light and dark modes - Added
aria-pressedstate to all filter/segmented-control buttons in History and Logs pages, and wrapped filter groups inrole="group"with descriptivearia-labelfor screen-reader usability - Added
aria-current="page"to sidebar navigation and mobile bottom-nav buttons so the active page is announced by screen readers - Added
aria-labelto the search input on the History page - Changed mobile bottom-nav "More" button icon from a gear to a three-dots ellipsis, matching the standard convention for overflow menus
- Added an active indicator dot beneath the current page icon in the mobile bottom navigation bar
- Scoped the
htmltheme transition (background-color/color) inside@media (prefers-reduced-motion: no-preference)to respect the OS reduced-motion accessibility setting - Added scroll-fade gradient hint on mobile Dashboard stats row to indicate horizontal scrollability
- Replaced non-standard checkbox in Logs page auto-scroll control with a proper
role="switch"toggle button matching the design pattern used throughout the Settings page - Removed duplicate action buttons on Jobs, Storage, and Replication pages – top-right header button now only appears when items exist, eliminating redundancy with the empty-state center button
- Fixed nil pointer dereference on startup:
BrowseHandlerwas not assigned to the server struct insetupRoutes, causing a panic whenSetZFSListerwas called - Fixed
sync.Mutexdeadlock in runner:RunJobalready holdsr.mu, removed redundant lock aroundsnapshotManageraccess in snapshot save - Clipboard copy in Settings UI now handles errors with an error toast instead of silently failing
- Middleware test
SetSettingcalls now check for errors to prevent misleading test results - API error responses no longer leak internal error details to clients; all 500 responses now return a generic "internal server error" message while the real error is logged server-side (OWASP A09)
- SMB storage adapter now enforces a 30-second dial timeout via
context.WithTimeoutto prevent indefinite connection hangs - SFTP adapter logs a warning when falling back to
InsecureIgnoreHostKeydue to missing host key verification configuration - Runner
SetSnapshotManagerwrite is now protected by mutex to prevent a data race with concurrent job execution - SMB
smbReadCloser.Close()now useserrors.Jointo surface file/share/session close failures instead of silently discarding them - NFS adapter
unmount()now logs errors fromumountand temp directory removal instead of silently discarding them - Mirrored SSD cache pools (e.g.
/mnt/cache2,/mnt/cache3) not detected under Settings → Database Location and Temporary Work Area – pool discovery now scans/mnt/at runtime using exclusion-based filtering (closes #49) - Browse handler filesystem roots now dynamically discover all pool drives instead of relying on a hardcoded "Cache" entry
- Path traversal vulnerability (CWE-22) in
SnapshotManager– addedvalidateSnapshotPathdefense-in-depth validation toSaveSnapshot,SetSnapshotPath,RestoreFromSnapshot,RestoreFromPath,SetUSBBackupPath, andsaveUSBBackupwith..component rejection beforefilepath.Clean+filepath.Absnormalisation; usesfilepath.ToSlashfor cross-platform traversal detection (closes #27, closes #28) - Data race in
SaveSnapshotreadingsnapshotPathwithout mutex protection – now reads the field under lock consistently with other accessors - Diagnostics collector hybrid-mode detection now checks that the preferred pool is mounted (matching daemon startup behaviour) instead of only checking directory existence
- CSRF token validation added to
control.phpfor state-changing actions (start, stop, restart, reset-config) – token sourced exclusively from POST - IPv6 loopback (
::1) bind address now connects via[::1]instead of127.0.0.1in the PHP proxy, fixing connectivity when the daemon binds exclusively to IPv6 - Bind-address validation in
apply.shandrc.vaultnow usesgrep -F(fixed-string) to prevent regex wildcard matching of IPv4 dots, andapply.shaccepts IPv6 loopback/wildcard (::1,::) apply.shno longer sources the config file directly – safely extracts only theBIND_ADDRESSkey via grep/sed to prevent arbitrary code execution from user-editable configapply.shnow checkssed -iexit status and aborts with an error if the config update fails- INI sanitisation in
control.phpnow strips backslashes in addition to quotes and newlines, preventing backslash-escape attacks on INI quoting - Tooltip clipping when positioned near viewport edges – switched from
position: absolutetoposition: fixedwith JS-calculated viewport coordinates and horizontal clamping - Container path exclusion presets now load correctly when Vault runs behind the Unraid web proxy;
fetchContainerPresets()usesbuildApiRequest()instead of rawfetch()to route through the authenticated proxy endpoint (closes #11) - Stuck backup jobs can no longer run indefinitely – timeout and stall detection ensure jobs are always bounded (closes #28)
- Time format detection now falls back to
[notify][time]indynamix.cfgwhen[display][time]is absent, fixing detection on Unraid 7.x where the time format preference is stored in the notification settings section - Unraid Settings/Vault page was blank due to duplicated PHP code in
api.phpcausing a syntax error; removed the corrupted duplicate block to restore the service control panel, Web UI button, and port/binding configuration - SMB and SFTP storage adapters now honour the "Path" field: frontend forms send
base_pathmatching the backend struct, and adapters accept the legacypathJSON key as a fallback for backward compatibility (closes #25) - Job deletion with "Delete Backup Files" now properly removes empty directories after deleting their contents, fixing the issue where backup files and directories were left on Local and SMB storage (closes #26)
- SMB adapter
Write()now propagatesMkdirAllerrors instead of silently ignoring them ItemPickerselected-items map wrapped in$state()to ensure Svelte 5 reactive tracking (closes #22)- Items deleted from Unraid (containers, VMs, folders, plugins) can now be removed from backup jobs via the new remove button in the Backup Order list; stale items that no longer exist on the system are visually flagged with a "Not found" warning indicator (closes #24)
- Storage form "Save" button now guards against double-submission with a
savingflag and shows a "Saving…" state while the request is in flight - Container volume backups now skip Unix sockets, character/block devices, and named pipes instead of failing with "sockets not supported" errors; affected containers (e.g. those mounting
/var/run/docker.sock) will complete successfully with a log entry for each skipped special file (closes #5) - Monthly schedule day picker now shows all 31 days instead of only days 1–28; previously
Array(27)omitted days 29, 30, and 31 (closes #9)
[2026.03.02] - 2026-03-19¶
Added¶
- Cleaned up documentation.
Fixed¶
- Tailscale enabled containers backups failing.
- UI/UX fixes and polish.
[2026.03.01] - 2026-03-18¶
Added¶
- MCP tools for plugin discovery and runner status
- Restore-point chain health annotations in the API and MCP restore-point listings
Changed¶
- MCP health output now includes version and mode, aligned with the REST
/healthresponse - README refreshed to document the current REST API, MCP transports, and tool coverage
make verifynow exercises MCP streamable HTTP via the official Go SDK client- Release packaging now targets
.txzbundles with SHA256 verification and release automation updates the PLG checksum accordingly - The PLG now advertises
projectandreadmemetadata and prunes stale cached plugin bundles during install
Fixed¶
- Restore-point docs now reflect chain health and retention-preserved parents
- Repository URLs now point at
ruaan-deysel/vault - UI Fixes
[2026.03.00] - 2026-03-02¶
Added in 2026.03.00¶
- Full backup/restore engine for Docker containers, libvirt VMs, and folders
- Storage backends: Local, SMB, NFS, SFTP, NFS
- Cron-based job scheduling with retention policies
- Svelte 5 web UI with real-time WebSocket progress
- API key authentication and TLS support
- Encrypted storage credentials (AES-256-GCM sealed passphrase)
- Replication: sync restore points to remote Vault instances
- Job duplication with one-click copy
- Mobile-responsive bottom navigation bar
- Pull-to-refresh on Dashboard and History pages
- Theme cycle keyboard shortcut (Ctrl+Shift+L)
- Aria-labels on all icon-only action buttons
- Backup size trend chart (filters to completed runs only)
- Ansible-driven build/deploy/verify pipeline
- Proper Unraid plugin bundle pattern with MD5 verification
- GitHub Actions release workflow with automatic PLG MD5 update
Fixed in 2026.03.00¶
- Backup trend chart no longer includes failed runs with partial sizes
[0.1.0] - 2025-01-01¶
Added in 0.1.0¶
- Initial release
- Docker container backup and restore (full image + config + appdata)
- VM backup and restore (live snapshot + cold backup)
- Storage destinations: Local, SMB, NFS, SFTP, NFS
- Full, incremental, and differential backup types
- Job scheduling with retention policies
- Web UI with Dashboard, Jobs, Restore, Storage, History, Settings