# LibrePortal — Storage Locations (per-app data placement) **Status:** Phases 0-5 **built** (incl. the setup-wizard Storage step); the Disks WebUI page is not. · **Audience:** us, future-self · **Scope:** register more than one filesystem root for live app data, choose one per app, move an app between them, and resolve the right one on restore/migrate · **Origin:** "add different locations to set up LibrePortal on, with control per app" (2026-08-24) --- ## 0. The one idea Today LibrePortal has **three** relocatable roots (`--system-dir` / `--containers-dir` / `--backups-dir`), each chosen once at install and fixed afterwards. This adds a fourth degree of freedom, on top of the existing containers root: > **The containers root becomes a *list*. Every app declares which entry in that list holds its data. Everything else — install, compose, backup, restore, migrate — resolves the app's directory through one function instead of one variable.** Nextcloud's 4 TB of photos go on the spinning disk. Vaultwarden and the control plane stay on the NVMe. Jellyfin's library lives on the external HDD that isn't always plugged in — and when it isn't, LibrePortal refuses to start Jellyfin rather than silently rebuilding it empty on the bare mountpoint. ## 1. Non-goals - ❌ Relocating the **system** root (configs/db/logs) per-app. It stays one place, chosen at install. Same for the WebUI's own container dir. - ❌ A general volume manager. We don't format, partition or mount anything — the drive must already be mounted; we validate and use it. - ⚠️ **One exception, added deliberately:** on explicit request we append a single marked `/etc/fstab` entry so a registered drive comes back after a reboot (§6.3). Telling a non-expert "add this line to fstab yourself" is a wall, and the most likely outcome is a reboot where nothing starts. - ❌ Striping/tiering/RAID-alikes. One app's data lives on exactly one location. No splitting an app across two. - ❌ Per-*volume* placement inside an app (`./data` here, `./db` there). Location granularity is the app directory. Revisit only if a real need shows up. - ❌ Merging the backup-location and storage-location **registries**. They differ in trust, lifecycle and ownership, so they stay separate — but they may freely share a *drive*, which is a supported and expected setup (§6.2). ## 2. What already works in our favour The current code is closer to this than it looks: - **Compose volumes are relative.** Every shipped template uses `./data:/config`, and `dockerComposeUp` does `cd $containers_dir$app_name && docker compose …`. Change the `cd` target and the app comes up unchanged, with the same compose project name (derived from the directory *basename*, which doesn't change). The only absolute host paths in any template are `/etc/localtime`, `/dev/net/tun`, `/etc/ssl/certs` and the docker socket — none of them ours. - **An app dir is self-describing.** `/.config` + `docker-compose.yml` + `.libreportal-manifest.json` is everything needed to identify and rebuild it. Discovery by scanning is viable, so we never have to trust a stale index. - **The three-root split already exists** end-to-end: flag parsing, `libreportalValidatePaths`, the nesting/protected-path refusals, the baked `__CONTAINERS_DIR__` placeholders, and `paths.sh` as the single source of truth. We're generalising a design that's already there, not inventing one. - **Backup locations are the exact template** for the registry UX: `configs/backup/locations//location.config`, `locationAdd`/`locationRemove`, `sourceBackupLocations`, a `libreportal backup location …` CLI, a WebUI *Locations* page, and `backupLocationLocalGuard` — which already implements the FAT/exFAT warning and the `REQUIRE_MOUNT` refusal we need verbatim. - **`instance create` proves the model.** "An instance is just another app" — a cloned dir with its own slug and `CFG__*` namespace. Per-app placement inherits multi-instance support for free. ## 3. The central constraint — root must not be told where to `chown` This decides the whole design, so it leads. The manager (`libreportal`) runs with a **scoped sudoers allowlist**: it may run the root-owned helpers in `/usr/local/lib/libreportal/` and a fixed system-binary set, and nothing else. No `sudo chown`, no `sudo tee`, no `sudo bash`. The helpers have the three roots **baked in at install** by `sed` (`__CONTAINERS_DIR__` …) precisely so that: > the manager cannot redirect a root `chown` by editing a config file. `libreportal-ownership` even re-checks its baked roots against a dangerous-path list as defence in depth. A naïve "storage locations live in a manager-writable config, and the helpers read it" **reopens that hole completely** — `chown -R dockerinstall /etc` is a full escalation. **The fix: a root-owned registry with an empty-directory admission rule.** 1. The truth lives at `/usr/local/lib/libreportal/storage.roots`, **root:root 0644**. One record per line: `idpathdevfs_uuid`. Manager reads it; only root writes it. Every helper resolves an app dir through it instead of through a single baked constant. 2. Adding a location goes through a new helper, `libreportal-storage add `, which is in the sudoers allowlist — so the manager *can* call it, but it accepts a path only if **all** of these hold: - absolute, and `realpath -e` returns the input unchanged (no symlink component, no `..`) - not in the protected set (`/ /etc /usr /bin /sbin /lib* /boot /proc /sys /dev /run /var /tmp /root /home`), and not inside any user's home unless the install was made with `--allow-home` - does not nest — in either direction — with the system/containers/backups roots or any already-registered location - **is an empty directory** (tolerating only `lost+found`) **or already carries a `.libreportal-storage` marker** — the adopt case, §3.1 The emptiness rule is what makes this safe: **root only ever chowns a directory that contains nothing**, so acceptance can't hand away anything that already existed. Everything created underneath afterwards is ours by construction. 3. On acceptance the helper writes a root-owned `.libreportal-storage` marker (location id + install id + created-at), `chown`s the root to the container owner, `chmod 0751`, and appends the record. `remove` refuses while any app dir still lives there. Consequence worth stating plainly: **`storage add` is a genuine privilege boundary crossing, not a config edit.** The WebUI can drive it (through the task system → CLI → helper, same as every other mutating action), but the *helper*, not the WebUI, is the gate. ### 3.1 — Why "empty **or** marked" is still safe (and why the marker earns its keep) Requiring strict emptiness would break the most valuable case there is: plugging in a drive that **already holds LibrePortal app data** from another install and adopting it. So the rule relaxes to *empty or carrying our marker* — and that relaxation costs nothing, because: > writing the marker into a directory requires already being able to write that directory. The manager can only plant a marker somewhere it can already write, and chowning a directory it already controls grants it nothing. There is no path where the marker gets the manager access it didn't have. (The fixed-path helpers can't be tricked into writing one elsewhere — none of them takes a caller-supplied destination.) That single file then does **three** jobs, which is the main reason to like it: | Job | How | |---|---| | Admission | "empty or marked" — §3 | | **Mount detection** | the marker lives *on the drive*. Not mounted ⇒ bare mountpoint ⇒ no marker ⇒ location unavailable. No `findmnt`, no `fs_uuid` bookkeeping, and it works identically for USB disks, network mounts, and LUKS volumes that haven't been unlocked (§10.1) | | Provenance on migrate | it carries the install id and location id the snapshot's manifest names, so "is this the same `bigdisk` the app came from?" is a file read (§9) | **Residual wrinkle, not solved by elegance:** validate-then-`chown` is a TOCTOU window, and bash is a poor language for race-free path handling. The practical closure is to additionally require that the **parent** directory is not manager-writable — true for `/mnt`, `/srv`, `/media`, false for a path inside the manager's home. That's a real restriction on where locations may live, not a free lunch, and it should be stated in the docs rather than hidden. ## 4. The resolution layer — one function, ~200 call sites The mechanical bulk of the work. Three new primitives in `scripts/source/paths.sh` (and mirrored inline in `init.sh`, per the existing keep-in-sync note): ```bash storageRoots # every enabled root, primary first appDir # the app's directory — memoised pathIsContainerData

# is this path under ANY container root? ``` - **`appDir`** builds a `slug → dir` map once per process by scanning each enabled root for `*/.config`, memoises it in an associative array, and falls back to the primary root for a slug that doesn't exist yet (fresh install). Discovery-first, config-second: if the map and `CFG__STORAGE` disagree, **the directory on disk wins** and we warn. That's what makes the system self-healing after a hand-move or a half-finished migration. It is also **the single place the availability check belongs**. Every one of the ~200 sites has to call it, so a location whose drive is missing fails *once*, centrally, instead of needing a guard sprinkled at each caller. `appDir` returns non-zero **and** prints an unusable sentinel path, so the many callers that won't check `$?` still fail loudly on a path that cannot exist, rather than writing into a bare mountpoint. See §10.1. - **`pathIsContainerData`** replaces the `[[ "$p" == "$containers_dir"* ]]` idiom that decides manager-vs-container-user elevation. It appears in ~10 files (`create_folder.sh`, `create_touch.sh`, `copy_file.sh`, `copy_files.sh`, `copy_folder.sh`, `copy_folders.sh`, `move_file.sh`, `runCfgOp`, `tags_manager_update.sh`, `webui_atomic_write.sh`). **Every one is a silent-corruption bug if missed** — a file under an unrecognised root gets written as the manager, lands with the wrong owner, and the container fails to read it at a moment far removed from the cause. Scale of the sweep (measured, not estimated): | Pattern | Count | Action | |---|---|---| | `$containers_dir$app`-shaped, app-scoped | ~186 across ~90 files | → `$(appDir "$app")` | | `$containers_dir/libreportal/…` (the WebUI's own tree) | 63 | → `webuiDir` helper, **pinned to the primary root** | | `$containers_dir/{traefik,prometheus,grafana,adguard,…}` — one app reaching into another by literal name | ~29 | → `appDir `; those apps stay pinned in phase 1 (§10.3) | | root-helper `$CONTAINERS_DIR/$app` | `libreportal-ownership`, `libreportal-appcfg` | → registry lookup inside the helper | | the `== "$containers_dir"*` elevation test | ~10 | → `pathIsContainerData` | `sourceScanFiles app_configs` must also loop the roots. Keep the existing `-maxdepth 3` + prune rules exactly as they are — and remember `*.config` is a reserved extension anywhere under an app dir. **Finding the sites we missed.** A 90-file mechanical diff is exactly where a silent regression hides, and review alone won't catch a `$containers_dir$app` that survived. Make the runtime find them instead: once the sweep lands, **stop defining `containers_dir` as a usable path** and point it at a sentinel (`/nonexistent-libreportal-unconverted/`). Legitimate primary-root users have moved to `primaryRoot`/`webuiDir` by then, so every survivor now fails immediately, loudly, and *harmlessly* — on a path that cannot exist — with a sentinel string that greps straight out of the logs. Silent wrongness becomes a stack trace. Then a `scripts/dev/` linter keeps the pattern from coming back, the way `lp-task-names` guards task titles. ## 5. Per-app configuration One new key in every app template, defaulting to the primary location: ``` # STORAGE = which storage location holds this app's data (see Storage on /admin) CFG_BOOKSTACK_STORAGE=default ``` - The value is a **location name**, not a path. Names are what survive a migrate to a host whose disks are laid out differently; paths aren't. `default` always resolves to the install-time containers root. - It renders as a dropdown on the app's config page for free — the WebUI config renderer already builds selects from the `[a:A|b:B]` comment convention; the option list is generated from the registry into the config comment on regen. - Changing it in the config editor does **not** move data. It records intent; the move is `libreportal app move` (§8). The config page shows the current *resolved* directory next to the field, and flags a mismatch. - `configBackfillMissingKeys` carries the new key into already-installed apps on the next update, so nothing needs a reinstall. - **New compose tag** `#LIBREPORTAL|APP_DIR_TAG|APP_DIR_DATA`, filled in `dockerConfigSetupFileWithData` alongside the existing `CONTAINERS_DIR_TAG`, for the rare template that genuinely needs its own absolute host path. Most apps need nothing — relative volumes already do the right thing. ### 5.1 — Why the value is a name and not `CFG__STORAGE_PATH` A path reads better in isolation — `grep STORAGE *.config` would tell you where everything lives without consulting anything. It loses on the two things that actually happen: - **Mount paths move.** External drives arrive at `/media//