Major refactor of backup and storage paths: - Per-drive restic repos at <drive>/backups/primary/restic/ - Per-app DB dumps at <drive>/backups/primary/<app>/db-dumps/ - Remove global BackupDir, DBDumpDir, ResticRepo config fields - Add SystemDataPath config (fallback for apps without HDD) - New backup/paths.go with pure path computation helpers - Add GetStackHDDPath to StackDataProvider interface - Restic methods now accept repoPath as parameter - Cross-drive backup uses new secondary path structure - Rename storage/ to appdata/ in scripts and compose templates - Update protected HDD paths (storage → appdata + backups) - Simplify backup UI (remove global path displays) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
18 KiB
TASK.md — v0.14.0 Storage & Backup Architecture Overhaul
Version: v0.14.0 Type: Architecture overhaul — storage paths, backup structure, multi-drive support Scope: Controller Go code + app catalog compose files + setup scripts Note: Demo node will be reinstalled from scratch — no migration needed
Design Overview
New directory structure (per drive)
Every drive mount (/mnt/sys_drive, /mnt/hdd_1, /mnt/hdd_2, ...) uses the same layout:
/mnt/<drive>/
appdata/<app>/ ← live app data (renamed from "storage")
backups/
primary/
<app>/db-dumps/ ← raw DB dumps per app (accessible for testing)
restic/ ← per-drive restic repo (all apps on this drive)
secondary/
<app>/rsync/ ← rsync copies from apps on OTHER drives
restic/ ← restic repo for secondary copies
Dokumentumok/
media/
Download/
movies/
series/
music/
audiobooks/
Key rules
- An app's "home drive" = the drive from its
HDD_PATHenv var, orcfg.Paths.SystemDataPathif no HDD_PATH - Primary backup lives on the SAME drive as the app — protects against accidental deletion, app bugs
- Secondary backup lives on a DIFFERENT drive — protects against drive failure
- One restic repo per drive (in both primary and secondary) — same password for all repos
- DB dumps are raw SQL files per-app, always on the app's home drive, also included in restic
- Compose configs + controller.yaml go into EVERY primary restic repo (small, ensures self-contained restore)
storage/→appdata/rename across all compose templates- Filebrowser mounts per-drive subdirectories:
media/,Dokumentumok/,backups/secondary/(for file recovery)
Phase 1: Config & path helpers
1a. internal/config/config.go
Add:
SystemDataPath string \yaml:"system_data_path"`toPathsConfig— default/mnt/sys_drive`
Remove from struct:
BackupDir stringfrom PathsConfigDBDumpDir stringfrom PathsConfigResticRepo stringfrom BackupConfig
Keep:
ResticPasswordFile stringin BackupConfig (shared across all repos)HDDPath stringin PathsConfig (legacy, still used as default storage)
Update applyDefaults():
- Remove:
d(&cfg.Paths.BackupDir, "/srv/backups") - Remove:
d(&cfg.Paths.DBDumpDir, "/srv/backups/db-dumps") - Remove:
d(&cfg.Backup.ResticRepo, "/srv/backups/restic-repo") - Add:
d(&cfg.Paths.SystemDataPath, "/mnt/sys_drive")
Gotcha: All code referencing cfg.Paths.BackupDir, cfg.Paths.DBDumpDir, cfg.Backup.ResticRepo will break. Grep for all references and update.
1b. New file: internal/backup/paths.go
Path computation helpers (pure functions, no state):
package backup
import "path/filepath"
func PrimaryBackupPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "primary")
}
func PrimaryResticRepoPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "primary", "restic")
}
func AppDBDumpPath(drivePath, stackName string) string {
return filepath.Join(drivePath, "backups", "primary", stackName, "db-dumps")
}
func SecondaryBackupPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "secondary")
}
func AppSecondaryRsyncPath(drivePath, stackName string) string {
return filepath.Join(drivePath, "backups", "secondary", stackName, "rsync")
}
func SecondaryResticRepoPath(drivePath string) string {
return filepath.Join(drivePath, "backups", "secondary", "restic")
}
func AppDataPath(drivePath, stackName string) string {
return filepath.Join(drivePath, "appdata", stackName)
}
1c. App drive resolution
Need a method to determine which drive an app lives on. Add to the backup Manager or StackDataProvider:
// GetAppDrivePath returns the drive path for an app.
// Uses HDD_PATH from app.yaml if set, otherwise falls back to system data path.
func (m *Manager) GetAppDrivePath(stackName string) string {
if mounts := m.stackProvider.GetStackHDDMounts(stackName); len(mounts) > 0 {
// The HDD_PATH is the mount point — extract the drive from the first mount
// e.g., /mnt/hdd_1/appdata/immich → /mnt/hdd_1
// Actually, we need the HDD_PATH itself, not the mounts
}
return m.systemDataPath
}
Gotcha: GetStackHDDMounts returns resolved mount paths (e.g., /mnt/hdd_1/appdata/immich), not the raw HDD_PATH value. Need a way to get the raw HDD_PATH for a stack. Options:
- Add
GetStackHDDPath(name string) stringtoStackDataProviderinterface - Or: derive the drive from mount paths by finding the common
/mnt/<drive>prefix - Best: add to StackDataProvider — clean, explicit
1d. StackDataProvider interface update
In internal/backup/appdata.go, add:
type StackDataProvider interface {
// ... existing methods ...
GetStackHDDPath(name string) string // NEW: raw HDD_PATH from app.yaml
}
And implement in the stackAdapter in main.go.
Phase 2: DB dump refactor
2a. internal/backup/backup.go — DumpAll()
Currently dumps all DBs to one global directory (m.cfg.Paths.DBDumpDir).
Change to: For each discovered DB, determine the app's drive, dump to <drive>/backups/primary/<stack>/db-dumps/.
Key changes:
- Remove references to
m.cfg.Paths.DBDumpDir - Compute dump path per stack:
AppDBDumpPath(m.GetAppDrivePath(stack), stack) - Create dir if not exists before dumping
- Update
DumpResultto include per-stack dump paths
2b. internal/backup/backup.go — DumpStackDB()
Same refactor for single-stack dump (called by cross-drive before running Tier 2 backup).
2c. Status/validation
Currently RefreshCache() lists all dump files from one directory. Need to scan per-drive dump directories instead.
- Scan all registered drives (from settings or deployed stacks)
- For each drive, glob
<drive>/backups/primary/*/db-dumps/*.sql - Aggregate results
Phase 3: Restic backup refactor
3a. internal/backup/restic.go — ResticManager
Currently ResticManager has a single repoPath. Need to support multiple repos.
Option A: Make ResticManager stateless — pass repoPath per operation. Option B: Create multiple ResticManager instances.
Recommend Option A — cleaner for per-drive operations. Refactor all ResticManager methods to accept repoPath as parameter instead of using r.repoPath:
EnsureInitialized(repoPath string) errorRunBackup(ctx, repoPath string, paths []string, tags []string) (*SnapshotResult, error)ListSnapshots(repoPath string) ([]SnapshotInfo, error)GetRepoStats(repoPath string) (*RepoStats, error)RunCheck(repoPath string) errorRunPrune(repoPath string) error- etc.
Keep r.passwordFile, r.cacheDir, r.logger as instance fields.
3b. internal/backup/backup.go — RunBackup()
Currently:
paths := []string{stacksDir, dbDumpDir, controllerYaml}
paths = append(paths, appPaths...)
// one restic backup
Change to:
func (m *Manager) RunBackup(ctx context.Context) error {
// Group deployed stacks by drive
driveStacks := m.groupStacksByDrive()
infraPaths := []string{
m.cfg.Paths.StacksDir,
"/opt/docker/felhom-controller/controller.yaml",
}
for drivePath, stacks := range driveStacks {
repoPath := PrimaryResticRepoPath(drivePath)
m.restic.EnsureInitialized(repoPath)
var paths []string
// Always include infra (compose configs + controller.yaml) in every repo
paths = append(paths, infraPaths...)
for _, stack := range stacks {
// App data (appdata/<stack>/)
appData := AppDataPath(drivePath, stack.Name)
if _, err := os.Stat(appData); err == nil {
paths = append(paths, appData)
}
// DB dumps for this stack
dumpDir := AppDBDumpPath(drivePath, stack.Name)
if _, err := os.Stat(dumpDir); err == nil {
paths = append(paths, dumpDir)
}
}
// Tag with drive name for easy filtering
tags := []string{filepath.Base(drivePath)}
m.restic.RunBackup(ctx, repoPath, paths, tags)
}
}
3c. Prune, check, forget — per drive
Currently scheduled as single jobs. Need to loop over all active drive repos:
RunPrune()→ for each drive, prune that drive's primary restic repoRunCheck()→ sameRunForget()→ same
3d. Snapshot listing & stats — aggregate
For the backup page UI:
ListSnapshots()→ list from all primary repos, merge and sort by timeGetRepoStats()→ aggregate total size and snapshot count across repos- Tag snapshots with drive name so UI can optionally group them
3e. Monitoring pings
After ALL drive backups complete (not per-drive), send the backup ping. If ANY drive fails, the ping is not sent (or sent as failure).
Phase 4: Cross-drive (secondary) backup refactor
4a. internal/backup/crossdrive.go — runRsyncBackup()
Current: destDir = filepath.Join(destBase, "backups", "rsync", stackName)
New: destDir = AppSecondaryRsyncPath(destBase, stackName) → <dest>/backups/secondary/<stack>/rsync/
Update all path computations:
destDirconstruction_db/subdirectory (now under rsync/ too)_config/subdirectory- Size calculation path
4b. internal/backup/crossdrive.go — runResticBackup()
Current: repoPath = filepath.Join(destBase, "backups", "restic")
New: repoPath = SecondaryResticRepoPath(destBase) → <dest>/backups/secondary/restic/
4c. DB dump source path
Currently: r.dbDumpDir (global directory)
Now: per-app dump dir: AppDBDumpPath(appDrivePath, stackName)
The cross-drive runner needs to know the app's home drive to find its DB dumps.
- Add
GetAppDrivePathmethod to CrossDriveRunner (or pass via StackDataProvider)
Phase 5: Protected paths & delete safety
5a. internal/stacks/delete.go — ProtectedHDDPaths()
Current:
return map[string]bool{
hddPath: true,
filepath.Join(hddPath, "media"): true,
filepath.Join(hddPath, "storage"): true,
filepath.Join(hddPath, "Dokumentumok"): true,
filepath.Join(hddPath, "appdata"): true,
}
Change to:
return map[string]bool{
hddPath: true,
filepath.Join(hddPath, "appdata"): true,
filepath.Join(hddPath, "backups"): true,
filepath.Join(hddPath, "media"): true,
filepath.Join(hddPath, "Dokumentumok"): true,
}
Remove storage (gone), add backups.
Phase 6: Filebrowser mount sync
6a. internal/web/handlers.go — syncFileBrowserMounts()
Current: Mounts each registered path as one volume: <path>:/srv/<basename>
This exposes EVERYTHING on the drive, including encrypted restic repos and raw appdata.
Change to: Mount specific subdirectories per drive:
for _, sp := range paths {
driveName := filepath.Base(sp.Path) // "hdd_1", "sys_drive"
// User media
mediaPath := filepath.Join(sp.Path, "media")
if dirExists(mediaPath) {
storageMounts = append(storageMounts,
fmt.Sprintf(" - %s:/srv/%s/media", mediaPath, driveName))
}
// User documents
docsPath := filepath.Join(sp.Path, "Dokumentumok")
if dirExists(docsPath) {
storageMounts = append(storageMounts,
fmt.Sprintf(" - %s:/srv/%s/Dokumentumok", docsPath, driveName))
}
// Secondary backup copies (rsync — browseable for file recovery)
secPath := filepath.Join(sp.Path, "backups", "secondary")
if dirExists(secPath) {
storageMounts = append(storageMounts,
fmt.Sprintf(" - %s:/srv/%s/backups:ro", secPath, driveName))
}
}
This gives Filebrowser users access to:
- Their media files (movies, music, etc.)
- Their documents
- Secondary backup copies (rsync) for file recovery
- NOT raw appdata (dangerous), NOT restic repos (useless)
Phase 7: App catalog changes
7a. Compose file updates (app-catalog-felhom.eu)
All 11+ apps with needs_hdd: true: rename ${HDD_PATH}/storage/ → ${HDD_PATH}/appdata/ in volume mounts.
Apps to update (grep for storage/ in compose files):
- immich, paperless-ngx, audiobookshelf, calibre-web, emby, jellyfin, komga, navidrome, nextcloud, plex, radarr, romm, sonarr
Each compose file's volumes section changes, e.g.:
# Before:
- ${HDD_PATH}/storage/immich:/usr/src/app/upload
# After:
- ${HDD_PATH}/appdata/immich:/usr/src/app/upload
7b. Media-centric apps (Jellyfin, Plex, Emby, Radarr, Sonarr)
These apps also mount media directories. Check if they reference ${HDD_PATH}/media/ — if so, that's correct (no rename needed for media/).
7c. .felhom.yml files
The HDD_PATH field metadata doesn't reference storage/ — it just declares the env var. Description says "külső merevlemez elérési útja" which is fine. No changes needed.
Phase 8: Setup script updates
8a. scripts/docker-setup.sh
- Update
install_filebrowser()volume mounts to use new per-subdirectory pattern - Or: remove Filebrowser initial mounts entirely (controller will sync them on startup)
8b. scripts/hdd-setup.sh
- Update
STORAGE_DIRSto removestorage/entries - Update to use
appdata/naming - Or: mark as deprecated (controller handles disk init now)
Phase 9: controller.yaml update
New controller.yaml for demo node (after OS reinstall with SSD partition):
paths:
stacks_dir: "/opt/docker/stacks"
system_data_path: "/mnt/sys_drive"
backup:
enabled: true
restic_password_file: "/opt/docker/felhom-controller/data/restic-password"
db_dump_schedule: "02:30"
restic_schedule: "03:00"
retention:
keep_daily: 7
keep_weekly: 4
keep_monthly: 6
prune_schedule: "sunday"
No more restic_repo, db_dump_dir, backup_dir.
Phase 10: UI — Tároló section (simple update)
The Tároló section on the backup page needs to work with the new multi-drive, multi-repo architecture. Since we already agreed to show combined stats (not paths):
- Tier 1 summary: Aggregate snapshot count + total size across all primary repos
- Tier 2 summary: How many apps configured, total size
- Keep: Encryption key display (same password for all repos)
- Remove: Path displays, DB dump section (unnecessary detail)
Gotchas & risks
- Grep for ALL references to removed config fields:
BackupDir,DBDumpDir,ResticRepo,cfg.Backup.ResticRepo,cfg.Paths.DBDumpDir,cfg.Paths.BackupDir - ResticManager refactor changes all call sites — grep for
m.restic.in backup.go - DB dump path in crossdrive.go — currently
r.dbDumpDir(global). Needs per-app resolution. - Snapshot aggregation — merging snapshots from multiple repos for the UI. Need to handle different repo sizes, dedup by timestamp.
- New restic repo initialization — when a new drive is registered and first backup runs,
restic initmust succeed beforerestic backup. TheEnsureInitializedpattern already exists. - Empty drives — a drive with no apps deployed yet should NOT get a restic backup (empty paths). Skip drives with zero apps.
- The
systemDataPathas fallback — SSD-only apps (Mealie, Gokapi) have no HDD_PATH. Their drive iscfg.Paths.SystemDataPath. Make sure this path exists and is registered as a storage path. - Compose config files in multiple repos —
/opt/docker/stacks/is included in every drive's primary repo. This means the same files are in multiple repos. That's intentional (each repo is self-contained) but uses slightly more storage. - The
ParseComposeHDDMountsfunction references${HDD_PATH}withstorage/subdirs. After rename toappdata/, the compose files change, so the parsed mounts change too. The function itself is generic (parses any${HDD_PATH}prefix) so it doesn't need code changes — only the compose templates change. - docker-compose.yml volumes in felhom-controller — currently
- /srv/backups:/srv/backups. This mount becomes unnecessary since all backups are under/mnt/. The/mnt:/mnt:rsharedmount already provides access. Can remove the/srv/backupsvolume mount from the controller's compose file.
Implementation order
- Phase 1 — Config + path helpers + StackDataProvider update (foundation, everything depends on this)
- Phase 7 — App catalog compose files (independent, can do in parallel)
- Phase 5 — Protected paths (quick, independent)
- Phase 2 — DB dump refactor
- Phase 3 — Restic backup refactor (depends on Phase 1 + 2)
- Phase 4 — Cross-drive backup refactor (depends on Phase 1)
- Phase 6 — Filebrowser mount sync
- Phase 10 — UI Tároló section
- Phase 8 — Setup scripts
- Phase 9 — controller.yaml
Build, deploy to reinstalled demo node, verify.
Files to modify
Controller (deploy-felhom-compose/controller/)
| File | Phase | Changes |
|---|---|---|
internal/config/config.go |
1a | Add SystemDataPath, remove BackupDir/DBDumpDir/ResticRepo |
internal/backup/paths.go |
1b | NEW FILE — path computation helpers |
internal/backup/appdata.go |
1d | Add GetStackHDDPath to StackDataProvider |
cmd/controller/main.go |
1d | Implement GetStackHDDPath in stackAdapter |
internal/backup/backup.go |
2+3 | DumpAll, DumpStackDB, RunBackup, RefreshCache, GetFullStatus, RunPrune, RunCheck |
internal/backup/restic.go |
3a | Make repoPath a parameter, not instance field |
internal/backup/crossdrive.go |
4 | Update destination paths, DB dump source paths |
internal/stacks/delete.go |
5 | Update ProtectedHDDPaths |
internal/web/handlers.go |
6+10 | syncFileBrowserMounts, backupsHandler |
internal/web/templates/backups.html |
10 | Tároló section |
App catalog (app-catalog-felhom.eu/)
| File | Phase | Changes |
|---|---|---|
templates/*/docker-compose.yml (11+ files) |
7 | storage/ → appdata/ in volume mounts |
Scripts
| File | Phase | Changes |
|---|---|---|
scripts/docker-setup.sh |
8 | Filebrowser mounts, path references |
scripts/hdd-setup.sh |
8 | Directory structure arrays |
Config
| File | Phase | Changes |
|---|---|---|
Demo node controller.yaml |
9 | New paths config |
Demo node docker-compose.yml |
10 | Remove /srv/backups mount |