# REPORT — userdata layout + shared-storage ownership convention → controller v0.66.1 + catalog (2026-06-14) Customer-facing `userdata/` tree with a shared-ownership convention so FileBrowser + content apps collaborate without permission collisions. Spike: `felhom.eu/documentation/audits/SPIKE-userdata-layout-2026-06-14.md`. Trunk-based on `main` in both the controller and the app-catalog. No back-compat (existing demo apps moved to the new layout — no real data). Built + deployed + live-verified on demo guest 9201. > Validate against pushed source at file:line, not this report. ## Confirmed baseline - Controller `main` was `cbaa53f` = **v0.65.0** → shipped **v0.66.0** then **v0.66.1** (first-deploy fix). - Catalog `main` was `5ec2b2c` (commits, no semver). ## Commit hashes | Repo | Commit | Scope | |---|---|---| | app-catalog | `69611ce` | repoint 12 composes off `${HDD_PATH}/media` → `${USERDATA_PATH}/...` + .felhom.yml | | app-catalog | `0d60a5c` | komga + audiobookshelf revert to root (user:1000 fallback) | | app-catalog | `c99070c` | komga + audiobookshelf `no-new-privileges:true` (root hardening) | | controller | `c48f95f` | v0.66.0 — userdata helper, USERDATA_PATH inject, skeleton, deploy belt, FileBrowser switch, migrate setgid fix + tests | | controller | `3b7d089` | v0.66.1 — fix USERDATA_PATH on first deploy (shared `withUserdataPath`) + regression test | ## What shipped - **Catalog**: `${USERDATA_PATH}` introduced; media/ingest mounts repointed per app (jellyfin/emby/plex `media:/media:ro`; navidrome `media/music:ro`; audiobookshelf `media/{audiobooks,podcasts}`; komga `media/comics`; calibre `media/books` + `import/calibre`, `UMASK=002`; radarr `media/movies`+`downloads`, sonarr `media/tv`+`downloads`, both `UMASK=002`; romm library→`userdata/roms`; immich +external `media/photos:/external/photos:ro`; paperless consume→`import/paperless`). nextcloud unchanged. - **Controller** (`internal/appbackup/userdata.go`): `EnsureUserdataDir`/`EnsureDirOwned` (MkdirAll → explicit `Chmod(ModeSetgid|0775)` → chown gid 1000), `UserdataSkeleton`, `EnsureUserdataSkeleton`; Linux `chownGID`/`StatGID` + non-Linux stubs. `withUserdataPath` injects `USERDATA_PATH=/userdata` in BOTH env builders. Skeleton pre-create on register + FB sync; deploy belt in `composeExecCustomEnv` (`up`). FileBrowser mounts `userdata` (uid 1000). `migrate.go` preserves setgid+group (#8). ## komga / audiobookshelf — user:1000 outcome: FELL BACK TO ROOT Tried `user: "1000:1000"` on both (catalog `0d60a5c`). **Both crash-looped** — their named config/metadata volumes are Docker-created root-owned and the pinned-1000 process can't write them (live: komga SQLite `/config` open failure, 14 restarts; audiobookshelf `EACCES mkdir '/metadata/logs'`; neither image has a PUID-style root-init chown). **Reverted to root** (the spec's accepted fallback) + added `no-new-privileges:true` (catalog `c99070c`) to restore an escalation boundary (full `cap_drop` NOT applied — the root-init needs CHOWN/SETUID/SETGID). Both then deploy **healthy as root** (audiobookshelf healthy; komga serving — see healthcheck note). Consequence: root+setgid → their output is **group- readable** (FileBrowser browses/reads), not group-writable; the app manages its own files. PUID-1000 apps (radarr/sonarr/calibre + UMASK 002) give **full group-write**. ## immich / paperless — DEPLOY DEFERRED (guest RAM), design verified Guest 9201 has **2 GiB RAM total**. immich needs ~4 GiB → cannot deploy. paperless (~1.1 GiB: app+postgres+redis) does not fit alongside the running apps; the controller's memory gate refuses it (observed: komga refused at 92 MB free until non-media apps were temporarily stopped). Their compose + run-identity design is shipped + statically verified (paperless `USERMAP 1000` consumes from `import/paperless`; immich `media/photos:/external/photos:ro`). **immich external library: MOUNT READY, REGISTRATION PENDING** — making /external/photos visible is only the mount; surfacing it in Immich requires registering an External Library at `Administration → External Libraries` (admin UI/API), a post-deploy step compose cannot do. Flagged in the immich compose + `.felhom.yml` first_steps. Not overclaimed. ## Live-verify evidence (guest 9201) — all three fixes PASS - **(c) convention**: `ls -la`/`stat` of `/mnt/felhom-usb/userdata` and every subdir → **mode 2775 (setgid) group 1000** throughout (media/{movies,comics,...}, roms, import/{paperless,calibre}, downloads, documents). Same on the system drive. - **(a) FileBrowser writes userdata**: `docker exec filebrowser` (uid 1000) `mkdir` + write a file under `/srv/felhom-usb/documents/...` → `MKDIR_OK`/`WRITE_OK`, new dir inherited gid 1000 via setgid. The original permission-denied (root-owned `appdata` mount) is gone. - **(b) shared media across apps**: radarr (uid 1000, UMASK 002) wrote `media/movies/Inception/Inception.mkv` → dir **2775**, file **664 group 1000**; jellyfin (root, `media:/media:ro`) `cat` it → `MOVIEDATA`; FileBrowser (uid 1000) listed **and deleted** it → full RW collaboration. komga (root fallback) wrote `media/comics/Batman` group 1000 → FileBrowser **lists/reads** but cannot delete (the documented root-fallback group-read tradeoff). - App health after moving to the new layout: jellyfin, calibre-web, romm, radarr, audiobookshelf all **Up healthy**; komga serving (HTTP up, tasks running, writes/reads OK) but its Docker healthcheck (`/api/v1/actuator/health`) reports **unhealthy** — a pre-existing healthcheck-probe nuance in the komga template, NOT a userdata/permission issue (no OOM, 0 restarts, logs clean). Flagged for a separate healthcheck fix. ## Tests (per-commit green; authoritative Linux gate on 192.168.0.180) `internal/appbackup`: SharedContentGID==1000, skeleton list+structure, `EnsureDirOwned` setgid+gid (Linux; in-test companion proves a plain MkdirAll has NO setgid). `internal/stacks`: `ParseComposeUserdataMounts` selectivity, deploy belt creates declared dirs, `withUserdataPath` on/off-by-HDD_PATH, **migration preserves setgid+group** (Linux — mutation-proven: reverting to 0755/.Perm() drops setgid → test FAILS). Full `go build && go vet && go test ./...` green. ## Follow-ups (small, noted not done) - FileBrowser-created dirs are 2755 (its umask 022) — group-read not group-write for OTHER actors; consider a FileBrowser `UMASK`/`user:1000:1000`-with-002 so customer-made folders are group-writable. - komga healthcheck endpoint (`/api/v1/actuator/health`) returns non-200 on komga 1.20 → "unhealthy" despite serving; fix the healthcheck definition. - immich/paperless live deploy needs a larger guest (the 2 GiB demo can't host them).