Maps current namespace dir creation/ownership (live on 9201), the 9-app/13-mount media blast radius, run-identity table (FileBrowser=uid1000, komga/audiobookshelf=root), LXC idmap, and proposes the setgid GID-1000 convention. Flags: MigrateAll's merge walk drops setgid+owner on userdata. Ends with a 7-item fork list. No code/deploy. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
19 KiB
SPIKE — userdata/ layout + shared-storage ownership convention (2026-06-14)
Report-only. NO code, NO catalog edits, NO deploy, NO version bump. This is the GATE before any implementation; it MAPS the current state and PROPOSES the change with file:line + live evidence, and ends with a FORK LIST for the operator.
Goal (operator-decided): introduce a customer-facing userdata/ dir under each drive's felhom-data
namespace, sibling to appdata/ and backups/. FileBrowser mounts userdata/. Apps that write to
${HDD_PATH}/media/... move into the userdata layout. A shared-ownership convention must let FileBrowser
- apps collaborate on userdata without permission collisions.
Access path used (operator can't login to the LXC): ssh root@felhom-pve (the Proxmox host,
192.168.0.162) → pct exec 9201 -- <cmd>; in-guest apps are Docker containers reached via pct exec 9201 -- docker exec <name> …. All "live:" evidence below was captured this way on guest 9201.
1. Current layout (LIVE) + who creates what
Live evidence
In-guest (Model A — /mnt/felhom-usb IS the felhom-data namespace root):
$ pct exec 9201 -- ls -la /mnt/felhom-usb/
drwxr-xr-x 3 root root … appdata
drwxr-xr-x 3 root root … backups
drwxr-xr-x 3 root root … media ← media/books (calibre-web), root:root mode 755
$ pct exec 9201 -- ls -la /mnt/felhom-usb/media/ → drwxr-xr-x root root books
$ pct exec 9201 -- ls -la /mnt/felhom-usb/appdata/ → drwxr-xr-x root root romm
Host view of the same drive (host /mnt/felhom-usb/felhom-data/ = guest /mnt/felhom-usb/):
$ ls -la /mnt/felhom-usb/felhom-data/
drwxr-xr-x 100000 100000 … appdata / backups / media ← all guest-root (uid 0 → host 100000)
System drive (in guest): namespace root is /mnt/sys_drive/felhom-data/ (NOT /mnt/sys_drive):
$ pct exec 9201 -- ls -la /mnt/sys_drive/felhom-data/ → appdata / backups / exports (all root:root)
No userdata/ exists anywhere yet (find /mnt -maxdepth 3 -name userdata → none).
Who creates each dir (code)
| Dir | Created by | Where | Owner / mode |
|---|---|---|---|
<drive>/felhom-data (additional drive) |
AGENT at guest-attach | felhom-agent/internal/localapi/guestbind.go:55-61: mkdir -p <drive>/felhom-data then chown 100000:100000 (NOT -R) |
guest-root 0:0, 0755 |
system-drive felhom-data/… |
provisioning + controller | scripts/docker-setup.sh does NOT create namespace dirs; the controller creates appdata on FileBrowser sync |
guest-root 0:0, 0755 |
appdata/ |
CONTROLLER | internal/web/handlers.go:~1464 syncFileBrowserMounts: os.MkdirAll(<drive>/appdata, 0755) |
guest-root 0:0, 0755 |
appdata/<app>, media/…, backups/<app> |
Docker auto-create (bind-mount source) on first app deploy, OR the backup code | docker daemon (runs as guest-root) | guest-root 0:0, 0755 |
Why media/ is guest-root (confirmed): the agent creates only the felhom-data ROOT (comment
guestbind.go:20-21: "per-app subdirs are chowned at deploy — NOT here"); nothing pre-creates media/,
so when calibre-web first deployed, the Docker daemon auto-created ${HDD_PATH}/media/books/... as
its bind-mount source — owned by the daemon (guest-root). This is the collision root cause: a
guest-uid-1000 app (FileBrowser, calibre) gets only r-x on a 0755 root-owned dir, never write.
2. HDD_PATH + namespace resolution
appbackup/paths.go:11const FelhomDataDir = "felhom-data";:24NamespaceRoot(drivePath, inGuestDrive)→ returnsdrivePathas-is wheninGuestDrive(Model-A user drive: the in-guest mount IS the namespace root), elsefilepath.Join(drivePath, "felhom-data")(SSD/system).:68AppDataDir(nsRoot, app)=<nsRoot>/appdata/<app>.${HDD_PATH}at deploy time = the registered StoragePath, which IS the in-guest namespace root. Live:romm/app.yaml→HDD_PATH: /mnt/felhom-usb; SSD apps (mealie, actualbudget) have no HDD_PATH (named volumes only). So${HDD_PATH}/mediaexpands to:- user drive:
/mnt/felhom-usb/media(guest) = host/mnt/felhom-usb/felhom-data/media - system drive (a media app pinned there):
/mnt/sys_drive/felhom-data/media
- user drive:
- Host→guest bind (
guestbind.go:64-66):pct set <vmid> -mpN <drive>/felhom-data,mp=/mnt/<name>— thefelhom-datasegment is consumed by the bind, so the guest sees the namespace at/mnt/<name>. - Therefore
${HDD_PATH}/userdataworks uniformly on both drive types (it's always namespace-relative).
3. App-catalog blast radius (the apps that must change)
Grepped app-catalog-felhom.eu/templates/*/docker-compose.yml. Apps writing customer content to
${HDD_PATH}/media/... (the ones the userdata move touches):
| App | file:line | Mount | RW/RO |
|---|---|---|---|
| jellyfin | jellyfin/docker-compose.yml:20 | ${HDD_PATH}/media:/media |
:ro |
| emby | emby/docker-compose.yml:21 | ${HDD_PATH}/media:/media |
:ro |
| plex | plex/docker-compose.yml:22 | ${HDD_PATH}/media:/media |
:ro |
| navidrome | navidrome/docker-compose.yml:22 | ${HDD_PATH}/media/music:/music |
:ro |
| sonarr | sonarr/docker-compose.yml:21-22 | ${HDD_PATH}/media:/media + ${HDD_PATH}/downloads:/downloads |
RW |
| radarr | radarr/docker-compose.yml:21-22 | ${HDD_PATH}/media:/media + ${HDD_PATH}/downloads:/downloads |
RW |
| audiobookshelf | audiobookshelf/docker-compose.yml:20-21 | ${HDD_PATH}/media/audiobooks + ${HDD_PATH}/media/podcasts |
RW |
| calibre-web | calibre-web/docker-compose.yml:29-30 | ${HDD_PATH}/media/books/ingest + ${HDD_PATH}/media/books/library |
RW |
| komga | komga/docker-compose.yml:19 | ${HDD_PATH}/media/comics:/data |
RW |
Total: 9 apps / ~13 media mounts must change (${HDD_PATH}/media → the userdata layout).
NOT in scope (app-exclusive appdata/ data, not shared media): immich
(${HDD_PATH}/appdata/immich), nextcloud (${HDD_PATH}/appdata/nextcloud), paperless-ngx
(${HDD_PATH}/appdata/paperless/{media,consume,export}), romm (${HDD_PATH}/appdata/romm/{library, resources}). These are single-app subtrees under appdata/ — moving them to userdata is a separate
decision (see ROMM + fork list). sonarr/radarr also use ${HDD_PATH}/downloads (staging, not final
library) — decide whether downloads moves under userdata too.
ROMM (flagged): romm/docker-compose.yml:65-66 →
${HDD_PATH}/appdata/romm/library:/romm/library + …/resources:/romm/resources. Compose-path only —
no in-app/env override (.felhom.yml only exposes HDD_PATH). The library lives under appdata/
today, so it is not customer-browsable. The operator wants a ROM import/library location under
userdata — that means repointing the library mount to ${HDD_PATH}/userdata/<…> (fork: exact path).
resources (cover art/metadata) is app-internal — leave under appdata.
4. App run-identity (decides the shared-group design)
| App | image | run identity | writes shared media? |
|---|---|---|---|
| filebrowser | gtstef/filebrowser:1.3.3-stable | uid 1000 (filebrowser) by image default — LIVE id=uid=1000 gid=1000; compose sets no user: |
RW (the customer's file manager) |
| calibre-web | crocodilestick/calibre-web-automated | PUID/PGID 1000 (linuxserver-style) | RW (books) |
| sonarr | lscr.io/linuxserver/sonarr | PUID/PGID 1000 | RW (media+downloads) |
| radarr | lscr.io/linuxserver/radarr | PUID/PGID 1000 | RW (media+downloads) |
| emby | emby/embyserver | UID/GID 1000 | :ro |
| paperless-ngx | paperless-ngx | USERMAP_UID/GID 1000 | (appdata only) |
| jellyfin | jellyfin/jellyfin | root (no PUID) | :ro |
| plex | plexinc/pms-docker | root (no PUID) | :ro |
| navidrome | deluan/navidrome | root (no PUID) | :ro |
| komga | gotson/komga | root (no PUID) — LIVE pattern | RW (comics) |
| audiobookshelf | advplyr/audiobookshelf | root (no PUID) | RW (audiobooks/podcasts) |
| romm | rommapp/romm | root — LIVE docker exec romm id=uid=0(root), Config.User="" |
(appdata; would be RW if moved) |
The 1000 cluster is large and natural: FileBrowser + the 5 PUID/PGID-1000 apps already share GID 1000. The :ro media apps (jellyfin/plex/emby/navidrome) only READ — they need group/other read, no write concern. The problem set = root-running apps that WRITE shared media: komga + audiobookshelf (and romm if its library moves to userdata).
5. FileBrowser — run UID + mount-change plan
- LIVE:
docker exec filebrowser id→uid=1000(filebrowser) gid=1000(filebrowser);Config.User=filebrowser. The gtstef image bakes uid 1000 even though the controller compose sets nouser:— so FileBrowser is already gid 1000. - Current mounts (LIVE
docker inspect):/mnt/sys_drive/felhom-data/appdata → /srv/felhom-dataand/mnt/felhom-usb/appdata → /srv/felhom-usb. Renderers:internal/web/handlers.go:~1464syncFileBrowserMounts(os.MkdirAll(<drive>/appdata,0755)+ bind…/appdata:/srv/<name>);internal/infra/infra.go:~116RenderFileBrowserCompose(image constFileBrowserImage = "gtstef/filebrowser:1.3.3-stable", nouser:, no PUID),:~211RenderFileBrowserConfig. - To mount
userdata/instead ofappdata/: changesyncFileBrowserMountsto<drive>/userdata:/srv/<name>(andMkdirAll userdata). (Scoping note: it was deliberately scoped toappdata/in Phase 4A to hide backups/recovery-units; pointing atuserdata/keeps backups hidden and exposes only customer media — strictly better.) - To run as the shared GID: FileBrowser is already uid/gid 1000. If the shared GID is 1000, no
change needed. If a dedicated felhom GID is chosen, add
user: "1000:<GID>"(orgroup_add) toRenderFileBrowserCompose. Withuserdatadirs at:1000/:GIDmode 2775, FileBrowser then has full RW.
6. LXC userns mapping
- LIVE
/etc/pve/lxc/9201.conf:unprivileged: 1,features: nesting=1,keyctl=1, no customlxc.idmap→ the default unprivileged map applies: guest uid/gid 0 → host 100000, guest 1000 → host 101000 (range 65536). Confirmed by the hostlsshowing guest-root dirs as100000:100000. - The convention only needs to be consistent inside the guest (guest uids/gids); the host-side numbers
are the +100000 image. The agent, running as host-root, can
chownto any host uid:gid, i.e. it can stamp guest0:1000as host100000:101000.
7. Ownership-convention recommendation (+ umask caveats)
Recommended convention: userdata/ and its subtree owned root:<sharedGID> (guest), mode 2775
(setgid + group-rwx). Setgid makes every file/dir created inside inherit <sharedGID> regardless of who
creates it. Members of <sharedGID>: FileBrowser + every customer-content app. Recommend
<sharedGID> = 1000 — it is already FileBrowser's gid and the PUID/PGID of calibre/sonarr/radarr/emby/
paperless, so 5 apps + FileBrowser need zero identity change.
The setgid bit fixes the GROUP; it does NOT fix the WRITE BIT — umask still applies. Caveats per app:
- linuxserver apps (calibre-web, sonarr, radarr): run as 1000 already; default
UMASK=022→ files 0644 (group can't write). SetUMASK=002(linuxserver supports it) so group keeps write. - root-running RW-to-shared apps (komga, audiobookshelf): write as root with umask 022 → group-read
only. Setgid stamps gid 1000 but FileBrowser still can't modify/delete. Each needs either a
user: "1000:1000"pin or aUMASK/umask-equivalent (komga:user:works; audiobookshelf: no PUID —user: "1000:1000"is the lever). Flag: verify each image starts cleanly when pinned to 1000. - :ro media apps (jellyfin/plex/emby/navidrome): read-only mounts — group/other read is enough; no umask concern.
- romm (if library moves to userdata): runs as root; same caveat as komga/audiobookshelf.
Pre-create userdata so Docker never root-auto-creates it again (the #1 fix). Creation points to patch:
- Additional drives:
felhom-agent/internal/localapi/guestbind.go:55-66— after creatingfelhom-data, alsomkdir -p <drive>/felhom-data/userdata/{media subdirs}+chown 100000:101000+chmod 2775(agent is host-root → can set guest0:1000). - System drive + per-deploy safety net: the controller — at app deploy (
internal/stacks/deploy.go) and/or the FileBrowser-syncMkdirAllsite (handlers.go:~1464) — ensureuserdata/<subdirs>exist with the shared GID + setgid beforedocker compose upso the daemon never auto-creates them root. (The controller runs as guest-root, so it can chown to0:1000+ chmod 2775.)
8. Migration interaction (controller v0.65.0) — ⚠ FLAG
MigrateAll copies "everything under felhom-data", so userdata/ is covered. But ownership/setgid
survival is split:
- App subtrees (
appdata/<app>+ recovery unit) go viarsyncCopy=rsync -a --checksum—-aincludes-o -g -p, and the controller runs as root in the guest (Dockerfile has noUSER), so owner/group and the setgid bit are preserved. ✓ - Non-app content (which is exactly
userdata/,media/, customer dirs) goes via the custom conflict-merge walk (migrate.gowalkMerge/copyFile), NOT rsync. That code doesos.MkdirAll(dst, 0o755)for dirs (drops the setgid bit + sets root:root) andcopyFilepreserves onlyfi.Mode().Perm()(.Perm()masks off setgid/setuid) and does not chown. So a MigrateAll would land userdata/ at the target asroot:root0755 — the shared-ownership convention would NOT survive a whole-drive migration. This must be fixed (re-apply the convention post-copy, or teachwalkMerge/copyFileto preserve setgid+group) as part of implementation. Per-appMigrateAppdoesn't touch userdata (it only moves the app's appdata+recovery-unit), so it's unaffected.
9. Transition (existing demo has media/ in the old layout)
- Live:
/mnt/felhom-usb/media/books(calibre-web) already exists, guest-root 0755. New deploys would useuserdata/but existing media stays atmedia/. - Options: (a) new-layout-for-new-deploys-only, leave existing
media/(simplest; existing apps keep working until re-deployed). (b) one-time movemedia/ → userdata/media/+ re-chown to the convention + redeploy affected apps. The just-shipped per-app migrate does NOT do this (it moves appdata between drives, notmedia→userdatawithin a drive); it would need a small dedicated migration step (mv + chown -R + chmod -R, then redeploy). Recommend (a) for the cutover + a small opt-in one-time fixup script for the demo, executed under supervision — not auto-run.
Summary table
| # | Finding | Evidence |
|---|---|---|
| 1 | media//appdata/ are guest-root 0:0 mode 755; Docker auto-creates bind sources as root (calibre's media/books proves it) — the collision cause |
live ls -la (guest + host 100000) |
| 2 | ${HDD_PATH} = the in-guest namespace root (/mnt/felhom-usb); ${HDD_PATH}/userdata works uniformly |
romm/app.yaml HDD_PATH=/mnt/felhom-usb; paths.go:11,24,68 |
| 3 | 9 apps / ~13 mounts use ${HDD_PATH}/media; ROMM library is compose-path-only under appdata |
catalog grep (table above) |
| 4 | FileBrowser + 5 apps already at uid/gid 1000; only komga + audiobookshelf (root, RW) are the problem set | live id + catalog |
| 5 | FileBrowser uid 1000 by image default, mounts appdata/ today; switch to userdata/ is a one-line mount change |
live docker exec filebrowser id; handlers.go:~1464, infra.go:~116 |
| 6 | unprivileged LXC, default idmap (0→100000, 1000→101000); convention is guest-internal | /etc/pve/lxc/9201.conf |
| 7 | Convention: userdata :1000 mode 2775 (setgid); umask caveat → komga/audiobookshelf need user:1000 or UMASK; linuxserver apps need UMASK=002 |
synthesis |
| 8 | ⚠ MigrateAll's non-app merge walk drops setgid+owner on userdata — convention won't survive a migration as-is | migrate.go walkMerge/copyFile |
| 9 | Existing media/ stays; recommend new-layout-for-new + supervised one-time fixup for the demo |
live |
FORK LIST (operator's call — GATE for implementation)
- Shared GID value —
1000(recommended: already FileBrowser's gid + the PUID/PGID of calibre/sonarr/radarr/emby/paperless; zero identity change for 6 of the players) vs a dedicatedfelhomgroup (cleaner semantics, but every app needsgroup_add/PUID set to it). - Common-group-only vs one-UID-for-all-apps (operator's open question):
- Common-group (setgid 2775 + per-app UMASK/user tweaks): preserves each app's native identity
(linuxserver PUID dance, root-init images work), but needs the umask fix on root-RW apps
(komga/audiobookshelf) and
UMASK=002on linuxserver apps. More per-app tuning, fewer image surprises. - One-UID-for-all (
user: "1000:1000"on every content app): simplest permissions (everything is 1000, no setgid/umask reasoning), but breaks images that must initialize as root — linuxserver images run their PUID/PGID setup as root then drop; pinninguser:skips that; some official images (jellyfin/plex) and root-only apps may misbehave. Higher image-compat risk. - Recommendation: common-group (setgid) with GID 1000, pin
user:/UMASK only on the few root-RW-to-shared apps.
- Common-group (setgid 2775 + per-app UMASK/user tweaks): preserves each app's native identity
(linuxserver PUID dance, root-init images work), but needs the umask fix on root-RW apps
(komga/audiobookshelf) and
- userdata subfolder set — confirm
media/{movies, "tv shows", music, audiobooks, books}. The space in"tv shows"is cosmetic and workable (quote in compose/scripts); a hyphen/underscore avoids quoting. Decide: keep the space, ortv-shows. Also: dodownloads/(sonarr/radarr staging) andcomics/(komga) live underuserdata/media/too? - ROMM import location under userdata — repoint
libraryto e.g.${HDD_PATH}/userdata/roms(browsable) while keepingresourcesunderappdata/romm? Confirm the exact path + that RomM tolerates an empty/new library dir. - Media path scheme — edit each compose to
${HDD_PATH}/userdata/media/...vs introduce a singleUSERDATA_PATHenv (one var, set at deploy like HDD_PATH; cleaner, one place to change, and makes a future layout change a one-liner). Recommendation:USERDATA_PATH. - Transition — new-layout-for-new-deploys-only (recommended) vs migrate existing
media/ → userdata/media/now (needs a supervised one-time fixup; the v0.65.0 per-app migrate does NOT cover this in-drive move). - (Surfaced, needs a call) Fix MigrateAll's merge walk to preserve setgid+group (or re-stamp the convention post-migration) — otherwise the shared-ownership convention silently degrades to root:root on any whole-drive migration (#8). Decide whether this rides in the same slice.