Files
deploy-felhom-compose/TASK.md
T

36 KiB
Raw Blame History

TASK: Phase 3 — Storage Overview & Per-App Backup Toggles

Version target: controller 0.8.0 Repos: deploy-felhom-compose (controller), app-catalog-felhom.eu (metadata)

Overview

Currently, restic backs up three fixed paths: the stacks directory (compose files), DB dumps, and controller.yaml. User data stored on the HDD (Paperless documents, media files, etc.) and Docker named volumes are not included.

Phase 3 adds:

  1. Storage overview on the backup page — SSD, HDD, and backup repo usage at a glance
  2. Per-app backup discovery — controller discovers each app's user data (HDD bind mounts + Docker volumes)
  3. Per-app backup toggles — customer enables/disables backup per app on the backup page
  4. Dynamic backup pathsRunBackup() includes enabled app data paths in the restic snapshot
  5. Restic password visibility — password shown on backup page (behind toggle) + synced to hub for disaster recovery
  6. Limited app restore — per-app restore from snapshot with warnings, self-service emergency recovery

1. Current State

What restic backs up today

// In backup.go RunBackup():
paths := []string{
    m.cfg.Paths.StacksDir,      // /opt/docker/stacks (compose files + .felhom.yml)
    m.cfg.Paths.DBDumpDir,      // /srv/backups/db-dumps (nightly dumps)
    "/opt/docker/felhom-controller/controller.yaml",
}

What's NOT backed up

  • HDD user data (e.g., /mnt/hdd_1/paperless/media, /mnt/hdd_1/romm/...)
  • Docker named volumes (e.g., actualbudget_data, docmost-postgres_data)

Existing infrastructure we can reuse

  • parseComposeHDDMounts(composePath, hddPath) — already discovers HDD bind mount paths per stack by parsing compose files
  • GetStackHDDData(name) — returns HDD paths + sizes for a stack
  • HDDPath struct with Path, SizeBytes, SizeHuman, Exists
  • HDD is already mounted into the controller container (read-only, fine for restic)
  • .felhom.yml has resources.needs_hdd: true flag
  • Monitoring page already shows SSD/HDD disk usage

Controller container volume mounts (relevant)

- /opt/docker/stacks:/opt/docker/stacks          # compose files
- /srv/backups:/srv/backups                        # restic repo + db dumps
- ${HDD_PATH}:${HDD_PATH}:ro                     # HDD (read-only)
- /var/run/docker.sock:/var/run/docker.sock:ro    # Docker socket

HDD data is accessible from the controller container for restic reads. Docker volume paths (/var/lib/docker/volumes/) are NOT mounted — volume backup requires a different approach (deferred).


2. Storage Overview (Backup Page Enhancement)

2.1 New section: "Tárhely áttekintés" (Storage Overview)

Add as the first section on the backup page, above the current "Ütemezés" section. Shows storage utilization relevant to backups:

┌─────────────────────────────────────────────────────────────┐
│ Tárhely áttekintés                                           │
│                                                              │
│ SSD (/)         ████████░░░░░░░░░░░░   42.1 / 512.0 GB (8%) │
│ Külső HDD       ██████████████░░░░░░   680.2 / 1000.0 GB    │
│                                        (68%)                 │
│                                                              │
│ Mentési tároló   2.4 GB  (/srv/backups/restic-repo)          │
│ DB mentések      142 MB  (/srv/backups/db-dumps)             │
│ Pillanatképek    14                                          │
└─────────────────────────────────────────────────────────────┘

2.2 Data source

Reuse system.GetInfo() for SSD/HDD disk stats (already available in SystemInfo). Add backup repo stats from existing RepoStats. DB dump dir size from ListDumpFiles().

No new backend code needed — just template work. The FullBackupStatus already contains RepoStats and the monitoring page already has SystemInfo.

Add SystemInfo to the backup page template data if not already present:

type BackupPageData struct {
    // existing fields...
    SystemInfo *system.Info  // for SSD/HDD bars
}

3. Per-App Data Discovery

3.1 App data classification

For each deployed app, classify its data into three categories:

Category Example Discovery method Backup support
HDD bind mounts /mnt/hdd_1/paperless/media parseComposeHDDMounts() (existing) This phase
Named Docker volumes paperless-ngx_postgres_data Parse compose volumes: section Future (not mounted in controller)
Config (stacks dir) /opt/docker/stacks/paperless-ngx/ Always backed up Already done

3.2 New struct: AppBackupInfo

// AppBackupInfo holds backup-relevant data paths for a deployed app.
type AppBackupInfo struct {
    StackName    string     // e.g., "paperless-ngx"
    DisplayName  string     // e.g., "Paperless-ngx"
    NeedsHDD     bool       // from .felhom.yml resources.needs_hdd

    // HDD bind mounts (backupable now)
    HDDPaths     []AppDataPath
    HDDTotalSize int64      // bytes
    HDDSizeHuman string

    // Docker named volumes (info only, not backupable yet)
    DockerVolumes []AppDockerVolume

    // Backup state
    BackupEnabled bool      // from settings.json
    HasHDDData    bool      // HDDPaths is non-empty
    HasDBDump     bool      // app has a database container (already covered by DB dump)
}

type AppDataPath struct {
    HostPath  string  // e.g., "/mnt/hdd_1/paperless/media"
    Exists    bool
    SizeHuman string
    SizeBytes int64
}

type AppDockerVolume struct {
    Name      string  // e.g., "paperless-ngx_postgres_data"
    Contains  string  // Human description (from .felhom.yml, or empty)
}

3.3 Discovery: DiscoverAppData()

New function in internal/backup/appdata.go:

// DiscoverAppData discovers backup-relevant data for all deployed apps.
func DiscoverAppData(stackProvider StackDataProvider, hddPath string, backupPrefs map[string]bool, discoveredDBs []DiscoveredDB) []AppBackupInfo {
    var result []AppBackupInfo

    for _, stack := range stackProvider.ListDeployedStacks() {
        info := AppBackupInfo{
            StackName:   stack.Name,
            DisplayName: stack.DisplayName,
            NeedsHDD:    stack.NeedsHDD,
        }

        // Discover HDD bind mounts (reuse existing parser)
        hddMounts := parseComposeHDDMounts(stack.ComposePath, hddPath)
        for _, mount := range hddMounts {
            path := AppDataPath{HostPath: mount}
            if fi, err := os.Stat(mount); err == nil && fi.IsDir() {
                path.Exists = true
                path.SizeBytes = getDirSizeBytes(mount)
                path.SizeHuman = getDirSizeHuman(mount)
            }
            info.HDDPaths = append(info.HDDPaths, path)
            info.HDDTotalSize += path.SizeBytes
        }
        info.HDDSizeHuman = humanizeBytes(info.HDDTotalSize)
        info.HasHDDData = len(info.HDDPaths) > 0

        // Discover Docker named volumes (from compose file)
        info.DockerVolumes = parseComposeNamedVolumes(stack.ComposePath)

        // Check if app has a DB container (already backed up via DB dump)
        for _, db := range discoveredDBs {
            if db.StackName == stack.Name {
                info.HasDBDump = true
                break
            }
        }

        info.BackupEnabled = backupPrefs[stack.Name]

        if info.HasHDDData || len(info.DockerVolumes) > 0 {
            result = append(result, info)
        }
    }

    return result
}

3.4 Named volume parser: parseComposeNamedVolumes()

// parseComposeNamedVolumes extracts named Docker volumes from a docker-compose.yml.
// Parses the top-level `volumes:` key. Skips external volumes.
func parseComposeNamedVolumes(composePath string) []AppDockerVolume {
    data, err := os.ReadFile(composePath)
    if err != nil { return nil }

    var compose struct {
        Volumes map[string]interface{} `yaml:"volumes"`
    }
    if err := yaml.Unmarshal(data, &compose); err != nil { return nil }

    var volumes []AppDockerVolume
    for name, cfg := range compose.Volumes {
        // Skip external volumes (networks like traefik-public)
        if cfgMap, ok := cfg.(map[string]interface{}); ok {
            if ext, ok := cfgMap["external"]; ok && ext == true {
                continue
            }
        }
        volumes = append(volumes, AppDockerVolume{Name: name})
    }
    return volumes
}

4. Per-App Backup Toggles

4.1 Storage in settings.json

Add to the Settings struct:

type Settings struct {
    // ... existing fields ...

    // Per-app backup preferences
    AppBackup map[string]AppBackupPrefs `json:"app_backup,omitempty"`
}

type AppBackupPrefs struct {
    Enabled bool `json:"enabled"`
}

Add getter/setter methods:

func (s *Settings) IsAppBackupEnabled(stackName string) bool
func (s *Settings) SetAppBackup(stackName string, enabled bool) error
func (s *Settings) GetAppBackupMap() map[string]bool  // stack_name -> enabled

4.2 Backup page: "Alkalmazás adatok" section

New section on the backup page, below "Tároló" (repo info), above snapshot history:

┌─────────────────────────────────────────────────────────────┐
│ Alkalmazás adatok                                            │
│                                                              │
│ Az alkalmazások felhasználói adatainak biztonsági mentése.    │
│                                                              │
│ ┌──────────────────────────────────────────────────────────┐ │
│ │ [✅] Paperless-ngx                         2.4 GB (HDD) │ │
│ │     /mnt/hdd_1/paperless/media (2.1 GB)                  │ │
│ │     /mnt/hdd_1/paperless/consume (312 MB)                │ │
│ │     📦 Docker kötet: paperless-ngx_data (nem mentett)    │ │
│ ├──────────────────────────────────────────────────────────┤ │
│ │ [☐] RoMM                                   8.7 GB (HDD) │ │
│ │     /mnt/hdd_1/romm/library (8.5 GB)                     │ │
│ │     /mnt/hdd_1/romm/assets (200 MB)                      │ │
│ ├──────────────────────────────────────────────────────────┤ │
│ │ [—] ActualBudget                                         │ │
│ │     📦 Docker kötet: actualbudget_data (nem mentett)     │ │
│ │     ️ Adatbázis mentés naponta (DB dump)                │ │
│ └──────────────────────────────────────────────────────────┘ │
│                                                              │
│ [Mentés]                                                     │
│                                                              │
│ ⚠️ Docker kötetek mentése jelenleg nem támogatott.           │
│ Az adatbázisokat az automatikus DB dump menti naponta.       │
└─────────────────────────────────────────────────────────────┘

UI behavior:

  • Toggle checkbox per app — only enabled for apps with HDD data
  • Apps with only Docker volumes show info state (no checkbox)
  • Apps with databases show "DB dump naponta" note
  • "Mentés" button saves all toggles at once via POST /settings/app-backup
  • Sizes update on page refresh (from the cached RefreshCache)

4.3 Route

Method Path Auth? Handler
POST /settings/app-backup Yes Save per-app backup toggles

Handler:

  1. Parse form: checkboxes named backup_{stack_name} (checked = enabled)
  2. For each app with HDD data: set enabled/disabled in settings
  3. Save to settings.json
  4. Refresh backup cache
  5. Redirect to /backups with success flash: "Alkalmazás mentési beállítások mentve."

5. Dynamic Backup Paths in RunBackup

5.1 Modify RunBackup() in backup.go

// Base paths (always backed up)
paths := []string{
    m.cfg.Paths.StacksDir,
    m.cfg.Paths.DBDumpDir,
    "/opt/docker/felhom-controller/controller.yaml",
}

// Per-app HDD data paths (from settings)
appPaths := m.resolveAppBackupPaths()
paths = append(paths, appPaths...)

m.logger.Printf("[INFO] Backup paths (%d total): %v", len(paths), paths)

5.2 Path resolver

func (m *Manager) resolveAppBackupPaths() []string {
    appBackupMap := m.settings.GetAppBackupMap()
    if len(appBackupMap) == 0 {
        return nil
    }

    var paths []string
    seen := make(map[string]bool)

    for stackName, enabled := range appBackupMap {
        if !enabled {
            continue
        }
        composePath, ok := m.stackProvider.GetStackComposePath(stackName)
        if !ok {
            m.logger.Printf("[WARN] App backup enabled for %s but stack not found", stackName)
            continue
        }
        hddMounts := parseComposeHDDMounts(composePath, m.cfg.Paths.HDDPath)
        for _, mount := range hddMounts {
            if seen[mount] {
                continue
            }
            if _, err := os.Stat(mount); err == nil {
                paths = append(paths, mount)
                seen[mount] = true
                m.logger.Printf("[DEBUG] Including app data: %s (from %s)", mount, stackName)
            }
        }
    }
    return paths
}

5.3 Stack data provider interface

To avoid circular imports between backup and stacks packages:

// In backup package
type StackDataProvider interface {
    GetStackComposePath(name string) (composePath string, ok bool)
    ListDeployedStacks() []StackSummary
}

type StackSummary struct {
    Name         string
    DisplayName  string
    ComposePath  string
    NeedsHDD     bool
}

Implement this interface with a thin wrapper in main.go or as an adapter:

// In main.go
type stackAdapter struct{ mgr *stacks.Manager }

func (a *stackAdapter) GetStackComposePath(name string) (string, bool) {
    s, ok := a.mgr.GetStack(name)
    if !ok { return "", false }
    return s.ComposePath, true
}
func (a *stackAdapter) ListDeployedStacks() []backup.StackSummary { ... }

5.4 Update FullBackupStatus

type FullBackupStatus struct {
    // ... existing ...
    AppDataInfo     []AppBackupInfo  // per-app backup info for backup page
    AppDataPaths    []string          // resolved app data paths (for "Mentett útvonalak" display)
    AppDataSizeHuman string           // total size of enabled app data
}

Populate AppDataInfo during RefreshCache() (runs periodically).


6. App Catalog Metadata Enhancement (Optional)

6.1 Add backup section to .felhom.yml

While the controller can discover HDD paths from compose files, explicit metadata gives better descriptions:

# In .felhom.yml for paperless-ngx
backup:
  description: "Dokumentumok, beolvasott fájlok és feldolgozási queue"
  data_paths:
    - "${HDD_PATH}/paperless/media"
    - "${HDD_PATH}/paperless/consume"
    - "${HDD_PATH}/paperless/export"
  docker_volumes:
    - name: "paperless-ngx_data"
      contains: "Alkalmazás belső adatai"
    - name: "paperless-ngx_postgres_data"
      contains: "Adatbázis (DB dump menti)"

Optional — the controller falls back to parseComposeHDDMounts() auto-discovery if the backup section doesn't exist.

6.2 Update Metadata struct

type Metadata struct {
    // ... existing fields ...
    Backup BackupMetadata `yaml:"backup" json:"backup"`
}

type BackupMetadata struct {
    Description   string              `yaml:"description" json:"description"`
    DataPaths     []string            `yaml:"data_paths" json:"data_paths"`
    DockerVolumes []VolumeDescription `yaml:"docker_volumes" json:"docker_volumes"`
}

6.3 Discovery priority

  1. If .felhom.yml has backup.data_paths → use those (resolve ${HDD_PATH})
  2. Else → fall back to parseComposeHDDMounts() auto-discovery
  3. Docker volumes: merge .felhom.yml descriptions with parsed compose volumes

7. Restic Password Visibility

7.1 Problem

The restic password is auto-generated on first backup and stored at /opt/docker/felhom-controller/data/restic-password inside the controller-data Docker named volume. If the SSD dies, that password is gone and ALL backup snapshots become permanently inaccessible. The customer currently has zero visibility into this password.

7.2 Password display on backup page

Add a "Titkosítási kulcs" (Encryption Key) section on the backup page, within the storage/repo area:

┌─────────────────────────────────────────────────────────────┐
│ Titkosítási kulcs                                            │
│                                                              │
│ A mentések titkosítva vannak. A visszaállításhoz szükség     │
│ van erre a kulcsra.                                          │
│                                                              │
│ [••••••••••••••••••••••]  [👁 Megjelenítés]  [📋 Másolás]  │
│                                                              │
│ ⚠️ Mentse el biztonságos helyre! A kulcs nélkül a           │
│ biztonsági mentések NEM állíthatók vissza.                    │
└─────────────────────────────────────────────────────────────┘

Implementation:

// Add to ResticManager
func (r *ResticManager) GetPassword() (string, error) {
    data, err := os.ReadFile(r.passwordFile)
    if err != nil {
        return "", fmt.Errorf("reading restic password: %w", err)
    }
    return strings.TrimSpace(string(data)), nil
}

Template behavior:

  • Password field is masked by default (type="password")
  • "Megjelenítés" button toggles visibility (JS, type="text")
  • "Másolás" button copies to clipboard (JS, navigator.clipboard.writeText())
  • Password is loaded in the template data (served over existing auth-protected page)

7.3 Password sync to hub

Add password to the periodic hub report so the operator has a backup copy:

// In the hub report payload (already sent every 15m)
type ReportPayload struct {
    // ... existing fields ...
    ResticPassword string `json:"restic_password,omitempty"`  // encrypted key for recovery
}

Hub side — store the password in the customer record. The hub is operator-controlled and already stores customer metadata. Add a restic_password field to the hub database for each customer.

Sync trigger:

  • On every periodic report (piggyback on existing 15-minute push)
  • On startup (controller init)

Security note: The hub API is already authenticated with Bearer token. The password travels over HTTPS (Cloudflare Tunnel). This is safer than the password being in a single Docker named volume with no redundancy.


8. Limited App Restore (Self-Service Emergency)

8.1 Approach

Provide a per-app "Visszaállítás" (Restore) feature on the backup page that restores an entire app's HDD data from a selected snapshot. This is a self-service emergency tool — customers can recover from accidental deletions without waiting for support.

Scope: Only restore HDD bind mount data for apps that have backup enabled. Compose files and controller.yaml are always restorable (they're in every snapshot).

8.2 UI: Restore section on backup page

Add a "Visszaállítás" section below the snapshot history:

┌─────────────────────────────────────────────────────────────┐
│ Visszaállítás                                                │
│                                                              │
│ Alkalmazás:  [▾ Paperless-ngx        ]                      │
│ Pillanatkép: [▾ 2026-02-16 03:00 (legutóbbi)  ]            │
│                                                              │
│ Visszaállítandó útvonalak:                                   │
│   /mnt/hdd_1/paperless/media                                │
│   /mnt/hdd_1/paperless/consume                              │
│                                                              │
│ ⚠️ FIGYELMEZTETÉS                                           │
│ A visszaállítás FELÜLÍRJA a kiválasztott alkalmazás          │
│ jelenlegi adatait a mentés pillanatának állapotával.         │
│ Ez a művelet NEM vonható vissza!                             │
│                                                              │
│ Javasoljuk az alkalmazás leállítását a visszaállítás előtt.  │
│                                                              │
│ [☐] Megértettem, visszaállítás saját felelősségre.           │
│                                                              │
│ [🔄 Visszaállítás indítása]  (disabled until checkbox)       │
└─────────────────────────────────────────────────────────────┘

8.3 Restore flow

  1. User selects app + snapshot from dropdowns
  2. JS fetches app's HDD paths (already in AppBackupInfo) and shows them
  3. User checks the "saját felelősségre" checkbox — this enables the button
  4. POST /backup/restore with stack_name, snapshot_id
  5. Handler validates:
    • Stack exists and has backup enabled
    • Snapshot ID exists in restic repo
    • HDD paths are valid
  6. Controller runs restic restore for the specific paths
  7. Redirect to backup page with result flash message

8.4 Backend: RestoreAppData()

// In ResticManager
func (r *ResticManager) RestoreAppData(snapshotID string, paths []string) error {
    // Build --include flags for each HDD path
    args := []string{
        "restore", snapshotID,
        "--target", "/",   // restore to original absolute paths
        "--password-file", r.passwordFile,
        "--repo", r.repoPath,
        "--cache-dir", r.cacheDir,
    }
    for _, p := range paths {
        args = append(args, "--include", p)
    }

    r.logger.Printf("[WARN] RESTORE started: snapshot=%s, paths=%v", snapshotID, paths)

    cmd := exec.Command("restic", args...)
    output, err := cmd.CombinedOutput()
    if err != nil {
        r.logger.Printf("[ERROR] Restore failed: %v, output: %s", err, string(output))
        return fmt.Errorf("restic restore failed: %w", err)
    }

    r.logger.Printf("[INFO] RESTORE completed: snapshot=%s, paths=%v", snapshotID, paths)
    return nil
}

8.5 Backup Manager: RestoreApp()

func (m *Manager) RestoreApp(stackName, snapshotID string) error {
    // Validate app has backup enabled
    if !m.settings.IsAppBackupEnabled(stackName) {
        return fmt.Errorf("backup not enabled for %s", stackName)
    }

    // Resolve HDD paths for this app
    composePath, ok := m.stackProvider.GetStackComposePath(stackName)
    if !ok {
        return fmt.Errorf("stack %s not found", stackName)
    }
    hddMounts := parseComposeHDDMounts(composePath, m.cfg.Paths.HDDPath)
    if len(hddMounts) == 0 {
        return fmt.Errorf("no HDD data paths found for %s", stackName)
    }

    // Validate snapshot exists
    snapshots, err := m.restic.ListSnapshots(100)
    if err != nil {
        return fmt.Errorf("listing snapshots: %w", err)
    }
    found := false
    for _, s := range snapshots {
        if s.ID == snapshotID {
            found = true
            break
        }
    }
    if !found {
        return fmt.Errorf("snapshot %s not found", snapshotID)
    }

    // Send notification before restore
    m.notify("restore_started", fmt.Sprintf("Visszaállítás indult: %s (snapshot: %s)", stackName, snapshotID))

    // Execute restore
    if err := m.restic.RestoreAppData(snapshotID, hddMounts); err != nil {
        m.notify("restore_failed", fmt.Sprintf("Visszaállítás sikertelen: %s — %v", stackName, err))
        return err
    }

    m.notify("restore_completed", fmt.Sprintf("Visszaállítás kész: %s (snapshot: %s)", stackName, snapshotID))
    return nil
}

8.6 Route

Method Path Auth? Handler
POST /backup/restore Yes Restore app HDD data from snapshot
GET /api/backup/snapshots Yes List snapshots (JSON, for restore dropdown)

Restore handler:

  1. Parse form: stack_name, snapshot_id
  2. Validate both fields present
  3. Call backupManager.RestoreApp(stackName, snapshotID)
  4. On success: redirect to /backups with flash " {app} visszaállítva ({snapshot})."
  5. On error: redirect to /backups with error flash " Visszaállítás sikertelen: {error}"

Snapshots JSON handler (for dynamic dropdown population):

func (s *Server) apiBackupSnapshotsHandler(w http.ResponseWriter, r *http.Request) {
    snapshots, err := s.backupManager.ListSnapshots(50)
    // return as JSON array
}

8.7 Important constraints

  • HDD must NOT be read-only for restore. The controller currently mounts HDD as :ro. For restore to work, the mount must be :rw OR a separate restore path must be used.

    • Change HDD mount to :rw (simpler, but less safe for normal operation)
  • Restore only for apps with enabled backup — if the user never enabled backup for an app, there's no data to restore.

  • No concurrent restore + backup — add a mutex/lock to prevent backup and restore running simultaneously.

  • Notification events: Add restore_started, restore_completed, restore_failed event types to the notification system.


9. Implementation Order

Step 1: Storage overview on backup page

  • Add SystemInfo to backup page template data
  • Add "Tárhely áttekintés" section to backups.html with SSD/HDD bars + backup repo stats
  • Reuse existing CSS for storage bars (from monitoring page)
  • Test: Backup page shows storage usage bars and repo stats

Step 2: Restic password visibility

  • Add GetPassword() to ResticManager
  • Add "Titkosítási kulcs" section to backups.html with masked password, show/copy buttons
  • Add password to hub report payload (restic_password field)
  • Hub side: store password in customer record (new DB column or JSON field)
  • Test: Password visible on backup page → copy works → hub DB has password after report cycle

Step 3: App data discovery + settings struct

  • Create internal/backup/appdata.go with AppBackupInfo, DiscoverAppData(), parseComposeNamedVolumes()
  • Define StackDataProvider interface in backup package
  • Add AppBackupPrefs + getter/setter to settings.go
  • Create stack adapter in main.go
  • Test: Call DiscoverAppData(), verify it finds paperless HDD paths

Step 4: Per-app toggles on backup page

  • Add "Alkalmazás adatok" section to backups.html with toggle checkboxes per app
  • Add POST /settings/app-backup handler
  • Include AppDataInfo in backup page template data (populate during RefreshCache)
  • Test: Toggle paperless backup on → save → check settings.json → page shows updated state

Step 5: Dynamic backup paths in RunBackup

  • Add resolveAppBackupPaths() to backup Manager
  • Modify RunBackup() to include enabled app HDD paths
  • Update BackupPaths display in FullBackupStatus
  • Test: Enable paperless backup → trigger manual backup → verify restic snapshot includes HDD data paths via restic snapshots --json

Step 6: Limited app restore

  • Change HDD container mount from :ro to :rw in controller docker-compose.yml
  • Add RestoreAppData() to ResticManager
  • Add RestoreApp() to backup Manager with validation and notifications
  • Add POST /backup/restore handler and GET /api/backup/snapshots JSON endpoint
  • Add "Visszaállítás" section to backups.html with app/snapshot dropdowns, warnings, confirmation checkbox
  • Add restore notification events (restore_started, restore_completed, restore_failed)
  • Add restore mutex to prevent concurrent backup/restore operations
  • Test: Enable paperless backup → backup → delete test file → restore → verify file is back

Step 7: Metadata enhancement (optional)

  • Add backup section to .felhom.yml for relevant apps in app-catalog repo
  • Update Metadata struct in metadata.go
  • Update discovery to prefer metadata over compose parsing
  • Test: Apps with backup.data_paths show correct descriptions and paths

Step 8: Cleanup & version bump

  • Update CONTEXT.md / CHANGELOG
  • Bump to 0.8.0
  • Build + deploy
  • Test end-to-end: Full cycle — enable app backup → nightly backup runs → restore works → password visible and synced to hub

10. Files to Create / Modify

New files:

  • controller/internal/backup/appdata.goAppBackupInfo, DiscoverAppData(), parseComposeNamedVolumes(), StackDataProvider interface
  • controller/internal/backup/restore.goRestoreAppData(), RestoreApp(), restore mutex

Modified files:

  • controller/internal/backup/backup.goRunBackup() uses dynamic paths, RefreshCache() builds AppDataInfo, add StackDataProvider field + resolveAppBackupPaths(), restore mutex integration
  • controller/internal/backup/restic.go — Add GetPassword(), RestoreAppData() methods to ResticManager
  • controller/internal/settings/settings.goAppBackupPrefs struct, getter/setter methods
  • controller/internal/web/handlers.goPOST /settings/app-backup handler, POST /backup/restore handler, GET /api/backup/snapshots JSON endpoint, pass AppDataInfo + password to backup page
  • controller/internal/web/templates/backups.html — "Tárhely áttekintés" section, "Titkosítási kulcs" section, "Alkalmazás adatok" section, "Visszaállítás" section
  • controller/internal/web/templates/style.css — Styles for app backup cards, toggle rows, restore section, password field
  • controller/internal/notify/notifier.go — Add restore_started, restore_completed, restore_failed event types
  • controller/internal/stacks/metadata.go — Add BackupMetadata to Metadata struct (optional)
  • controller/cmd/controller/main.go — Create stack adapter, wire into backup manager
  • Controller docker-compose.yml — Change HDD mount from :ro to :rw
  • Hub report payload struct — Add restic_password field
  • Hub API/DB — Store restic_password per customer (hub-side change)

App catalog (optional):

  • paperless-ngx/.felhom.yml — Add backup section with data paths and volume descriptions
  • Other apps with HDD data as applicable

11. Design Decisions & Notes

Why only HDD data in this phase?

Docker named volumes are stored at /var/lib/docker/volumes/ which is NOT mounted into the controller container. Backing them up would require either:

  • Mounting /var/lib/docker/volumes into the controller (security concern, large mount)
  • Running restic via a temporary Docker container (complex orchestration)
  • Using docker cp to export data (slow, no incremental)

For most apps, the important user data is on HDD (documents, media, photos). Database data in named volumes is covered by the nightly DB dump. Config-only volumes are small and recoverable from compose + deploy fields.

Why reuse parseComposeHDDMounts over explicit metadata?

Auto-discovery from compose files is zero-config for existing apps. The parser is already proven (used in the orphan delete workflow). Adding backup.data_paths to .felhom.yml is optional polish that gives better descriptions.

Why toggles on the backup page instead of per-app detail?

Backup is a cross-cutting concern. Having all toggles in one place on the backup page gives the customer a complete picture of what's protected. Individual app detail pages could show backup status and link to the backup page.

Backup size awareness

Enabling app data backup can dramatically increase backup duration and repo size (especially media files with low dedup potential). The repo lives on SSD (/srv/backups/restic-repo). Show an info banner:

" Az alkalmazás adatok mentésének bekapcsolása megnöveli a mentési időt és a tárhelyigényt."

The existing health check already warns on SSD disk usage > 80%.

HDD read-only mount is fine

The controller mounts HDD as :ro. Restic only reads files to create snapshots — read-only access is sufficient and more secure.

Circular import avoidance

The backup package needs stack data but shouldn't import the stacks package. Use a StackDataProvider interface defined in the backup package, implemented by a thin adapter in main.go. This keeps the dependency graph clean.

Future considerations

  • Docker volume backup — Phase 4: mount /var/lib/docker/volumes:ro into controller, or use a sidecar approach
  • Offsite backup — the "Távoli másolat" placeholder: second restic repo to B2/S3/SFTP
  • Selective file restore — browse individual files within snapshots (restic ls + restore --include). Phase 3 does whole-app restore only.
  • Backup scheduling per app — different retention or frequency for different apps
  • Backup size quota warnings — alert when backup repo exceeds a configurable threshold

Encryption architecture

All restic backups are encrypted at rest — restic has no unencrypted mode. This is a feature for a managed service ("your backups are encrypted even if someone accesses the drive"). The password is:

  1. Auto-generated (32 random bytes, base64url encoded) on first backup
  2. Stored locally at /opt/docker/felhom-controller/data/restic-password (Docker named volume)
  3. Displayed on the backup page behind a toggle (customer can copy/save it)
  4. Synced to hub via periodic report (operator has recovery copy)

If the SSD fails, the password can be retrieved from hub to access the offsite/HDD backup repo.

Why limited restore instead of full file browser?

A full file browser (restic ls per snapshot, pick individual files) adds significant UI complexity: directory tree rendering, file selection, path handling. For Phase 3, whole-app restore covers the primary emergency use case (accidental deletion, corruption). File-level restore can be added later as a power-user feature.

Why show the password to the customer?

The managed service model means the operator (Viktor) has hub access and can recover the password. But showing it to the customer:

  • Enables self-service disaster recovery if the customer is technically capable
  • Builds trust — the customer isn't locked out of their own backups
  • Reduces support burden for simple restore scenarios

The "behind toggle + clipboard" pattern prevents accidental exposure while making it accessible when needed.

Why sync password to hub?

The password lives in a Docker named volume on the SSD. If the SSD fails:

  • Without hub sync: password is permanently lost → all backups inaccessible
  • With hub sync: operator retrieves from hub → restores access

This is the single most critical piece of information for backup recovery. Hub sync provides the necessary redundancy.

HDD mount mode for restore

Changing from :ro to :rw is acceptable because:

  • The controller is a trusted, operator-managed component
  • It already has Docker socket access (much more privileged than filesystem write)
  • The :ro mount was a defense-in-depth measure, not a hard security boundary
  • Without :rw, restore would require either a sidecar container or host-level orchestration, adding significant complexity for minimal security benefit

Restore concurrency

Backup and restore both use the same restic repository. Running them concurrently can cause lock contention or corruption. A simple sync.Mutex in the backup Manager prevents this. The mutex is held for the duration of either operation.