admin 2b22a23d60 persistence sweep: 53 templates measured; gramps-web + wishlist fixed; runtime gate added
Campaign 10's R-156 found papra writing its database into the container's writable layer while the
volume the template preserves stayed empty — a backup that completes, verifies, and contains
nothing. papra was never the point: nothing anywhere checked that the folder a template preserves
is the folder the app writes to. All 53 templates have now been measured live.

43 CLEAN / 3 BROKEN / 7 UNDETERMINED. UNDETERMINED is counted separately, each with its reason,
and never folded into CLEAN.

FIXED (neither app is deployed anywhere, so nothing was stranded):
- gramps-web mounted /app/data, /app/media, /tmp — and /app/data is a path the application never
  writes. Its accounts database and ITS FAMILY TREE both landed in the writable layer while
  gramps_data was tarred nightly as an empty directory. Now persists the eight paths the image's
  own environment names, matching upstream's reference compose. Proven: users.sqlite and the
  family-tree files survive a redeploy byte-identical, same inode.
- wishlist mounted wishlist_data:/data, another path the app never writes; prod.db landed in the
  ANONYMOUS volume from the image's VOLUME directive — absent from ResolveDockerVolumeNames, so
  never backed up, and orphaned by a redeploy. Now mounts /usr/src/app/data + /usr/src/app/uploads.
  Proven: prod.db byte-identical, same inode, across a redeploy.

Every corrected path confirmed by two independent sources — the shipped image's own
environment/Config.Volumes and upstream's reference compose — never inferred from a directory name.

papra is NOT fixed. It is live on one box, and changing the mount target makes the next compose up
recreate the container and destroy the writable layer its documents live in. The fix is prepared
and proven in the scratch guest (current: db.sqlite differs after a redeploy, so a real account
created via the API is lost; fixed: byte-identical, it survives). Referred to the operator with the
two options; no migration written.

NEW GATE scripts/check-volume-persistence.py — the third catalog gate and the only RUNTIME one.
This class is invisible to static analysis, measured not assumed: a static audit of all 53 composes
reports the catalog clean AND reports papra clean. Exit 0 clean / 1 REFUSED / 2 undecided. It
refuses to report at all unless it has just re-proven itself in both directions against two canary
templates that differ only in which path the volume mounts at, so every run carries a live
demonstration of R-156 and of its fix. No docker exec anywhere (Campaign 7 §1.1). 44 fixture tests
driving check(), the function __main__ calls; every rule red-proofed.

Enforcement is convention, not CI — this repo has no CI. Stated plainly in the report; raising it
is proposed as R-160.

Report, per-app evidence, proofs and proposed register entries (R-158..R-161, NOT filed — felhom.eu
is fenced this session): audits/persistence-sweep-2026-08-02/
2026-08-02 12:21:30 +02:00

Felhom App Catalog

Central repository for Felhom customer application templates.

Architecture

app-catalog-felhom.eu/          <- This repo (source of truth)
├── templates.json              # Portainer App Templates index (LEGACY — Portainer-only setups)
├── templates/                  # Docker Compose templates with ${VAR} env var syntax
│   ├── actualbudget/
│   │   ├── docker-compose.yml
│   │   └── .felhom.yml         # App metadata for felhom-controller
│   ├── adventurelog/
│   ├── audiobookshelf/
│   ├── bentopdf/
│   ├── bookstack/
│   ├── calcom/
│   ├── calibre-web/
│   ├── claper/
│   ├── code-server/
│   ├── crafty-controller/
│   ├── docmost/
│   ├── emby/
│   ├── filebrowser/
│   ├── ghost/
│   ├── gitea/
│   ├── glance/
│   ├── gokapi/
│   ├── grafana/
│   ├── gramps-web/
│   ├── home-assistant/
│   ├── homebox/
│   ├── homepage/
│   ├── immich/
│   ├── jellyfin/
│   ├── kimai/
│   ├── komga/
│   ├── mealie/
│   ├── n8n/
│   ├── navidrome/
│   ├── nextcloud/
│   ├── onlyoffice/
│   ├── opengist/
│   ├── outline/
│   ├── paperless-ngx/
│   ├── papra/
│   ├── plex/
│   ├── privatebin/
│   ├── radarr/
│   ├── rallly/
│   ├── romm/
│   ├── seerr/
│   ├── sonarr/
│   ├── sparkyfitness/
│   ├── tandoor/
│   ├── termix/
│   ├── uptime-kuma/
│   ├── vaultwarden/
│   ├── vikunja/
│   ├── wanderer/
│   ├── wger/
│   ├── wishlist/
│   └── zipline/
└── scripts/
    └── generate-customer.sh    # LEGACY — generates customer-specific templates for Portainer

How It Works (Controller-based deployments)

The felhom-controller syncs directly from this repo:

  1. Controller periodically pulls this repo (configurable interval, default 15m)
  2. Copies docker-compose.yml and .felhom.yml from templates/<app>/ to /opt/docker/stacks/<app>/
  3. Never overwrites app.yaml (deployed config/secrets) or .env files
  4. Only copies files if content has actually changed (SHA-256 comparison)
  5. After sync, triggers a stack rescan so new/updated apps appear on the dashboard
  6. Manual sync available via "Sablonok frissítése" button on the Alkalmazások page

Controller git config (in controller.yaml)

git:
  repo_url: "https://gitea.dooplex.hu/admin/app-catalog-felhom.eu.git"
  branch: "main"
  sync_interval: "15m"
  username: ""     # Optional, for private repos
  token: ""        # Optional, for private repos

.felhom.yml Format

Each app template has a .felhom.yml metadata file that the controller uses for:

  • Display info on the dashboard (name, description, category, subdomain)
  • Resource hints (memory request/limit, Pi compatibility, HDD requirement)
  • Deploy fields (what the user fills in during first deployment)

Field types

Type Description
domain Auto-filled from controller config, read-only
secret Auto-generated, hidden from user (user sees "Generated ✓")
password Auto-generated but shown, user can override
path Filesystem path (validated for existence)
text Free text input
select Dropdown with predefined options
boolean Toggle switch

Generator types (for secret/password fields)

Generator Description
password:N N chars alphanumeric
hex:N N bytes hex-encoded
base64key:N base64: + N random bytes base64-encoded (Laravel APP_KEY format)
static:VAL Fixed value

Example .felhom.yml

display_name: "Paperless-ngx"
description: "Dokumentumok digitalizálása és rendszerezése"
category: "productivity"
subdomain: "paperless"
slug: "paperless-ngx"
resources:
  mem_request: "500M"
  mem_limit: "1152M"
  pi_compatible: true
  needs_hdd: true
deploy_fields:
  - env_var: DOMAIN
    label: "Domain"
    type: domain
    locked_after_deploy: true
  - env_var: DB_PASSWORD
    label: "Adatbázis jelszó"
    type: secret
    generate: "password:24"
    locked_after_deploy: true
  - env_var: HDD_PATH
    label: "Adattárolási útvonal"
    type: path
    required: true
    placeholder: "/mnt/felhom-drives/hdd_1"
    locked_after_deploy: true

App-email mapping (smtp_mapping)

Apps that can send outbound email (password resets, invites, confirmations) get it through one managed path: app → in-controller SMTP shim → hub → Resend. The Resend key stays hub-side; nothing app-specific lives on the box. An app opts in by declaring smtp_mapping, which renames the generic relay settings to that app's own env-var names.

When app-email is on (the household's global toggle AND the app's per-app toggle), the controller injects at deploy/redeploy: host = the on-box shim, port = 2525, from = <from_local>@felhom.eu, the security value, the optional display name, and any fixed extra vars. The values are derived from settings on every compose — never written to app.yaml — so a toggle change applies on the next redeploy without touching secrets. The shim accepts no-auth on the Docker network, so leave any SMTP_USERNAME/SMTP_PASSWORD unset.

The compose file must reference the mapped ${VAR} keys (with a harmless default) so the injected values reach the container, e.g. - SMTP_HOST=${SMTP_HOST:-}. An empty SMTP_HOST keeps the app's mail disabled when the toggle is off.

Field Meaning
host_var env key receiving the shim host (required)
port_var env key receiving the port 2525 (required)
security_var env key receiving the TLS mode (optional)
security_value the app's term for the chosen mode — starttls, TLS, NONE, …
from_var env key receiving the From address (required)
from_name_var env key receiving the From display name (optional)
from_local local-part of the From address (defaults to the app slug) → <local>@felhom.eu
extra fixed extra env (e.g. accept-invalid-cert flags)
# Vaultwarden — STARTTLS to the shim (it accepts the self-signed cert):
smtp_mapping:
  host_var: SMTP_HOST
  port_var: SMTP_PORT
  security_var: SMTP_SECURITY
  security_value: starttls
  from_var: SMTP_FROM
  from_name_var: SMTP_FROM_NAME
  from_local: vaultwarden
  extra:
    SMTP_ACCEPT_INVALID_CERTS: "true"
    SMTP_ACCEPT_INVALID_HOSTNAMES: "true"

TLS choice per app. Prefer STARTTLS to the shim for apps that can accept a self-signed cert (an "accept invalid certs" option). For apps that can't (e.g. Mealie), use plaintext (security_value: "NONE") on 2525 — the shim offers it on the Docker network and it was spike-validated. Never publish the shim off-box, so plaintext there is safe.

Currently mapped: Vaultwarden, Mealie (the two apps proven in SPIKE-smtp-app-relay-2026-06-28). Adding email to another app is just its smtp_mapping block + the matching compose ${VAR} lines.

App Catalog

App DB Type RAM (request / limit) Pi HDD Data Subdomain
ActualBudget None (file) 50M / 256M yes -- budget.*
AdventureLog PostgreSQL 100M / 384M yes -- travel.*
Audiobookshelf None (file) 100M / 512M yes ${HDD_PATH}/media/audiobooks/ audiobooks.*
BentoPDF None (file) 100M / 384M yes -- pdf.*
BookStack MariaDB 150M / 512M yes -- wiki.*
Cal.com PostgreSQL 200M / 768M no -- cal.*
Calibre-Web Automated None (file) 200M / 768M no ${HDD_PATH}/media/books/ books.*
Claper PostgreSQL 100M / 384M yes -- present.*
Code-Server None (file) 200M / 1024M no -- code.*
Crafty Controller None (file) 256M / 2048M no -- minecraft.*
Docmost PostgreSQL + Redis 200M / 768M no -- docs.*
Emby None (file) 512M / 2048M no ${HDD_PATH}/media/ emby.*
FileBrowser Quantum None (file) 50M / 256M yes ${HDD_PATH}/storage/filebrowser/ files.*
Ghost SQLite 150M / 512M no -- blog.*
Gitea SQLite 100M / 512M yes -- git.*
Glance None (file) 20M / 128M yes -- dashboard.*
Gokapi None (file) 30M / 128M yes -- share.*
Grafana None (file) 100M / 512M yes -- grafana.*
Gramps Web None (file) 100M / 384M yes -- family.*
Home Assistant None (file) 256M / 1024M yes -- ha.*
Homebox None (SQLite) 50M / 256M yes -- inventory.*
Homepage None (file) 50M / 256M yes -- home.*
Immich PostgreSQL + Redis 2048M / 4096M no ${HDD_PATH}/storage/immich/ photos.*
Jellyfin None (file) 512M / 2048M no ${HDD_PATH}/media/ media.*
Kimai MariaDB 100M / 384M yes -- time.*
Komga None (file) 200M / 512M yes ${HDD_PATH}/media/comics/ comics.*
Mealie None (SQLite) 200M / 1000M yes -- recipes.*
n8n None (file) 150M / 512M no -- auto.*
Navidrome None (file) 50M / 256M yes ${HDD_PATH}/media/music/ music.*
Nextcloud MariaDB + Redis 256M / 1024M no ${HDD_PATH}/storage/nextcloud/ cloud.*
OnlyOffice None (file) 512M / 2048M no -- office.*
OpenGist None (file) 30M / 128M yes -- gist.*
Outline PostgreSQL + Redis 200M / 768M no -- kb.*
Paperless-ngx PostgreSQL + Redis 500M / 1152M yes ${HDD_PATH}/storage/paperless/ paperless.*
Papra None (file) 50M / 256M yes -- papra.*
Plant-it None (file) 50M / 256M yes -- plants.*
Plex None (file) 512M / 2048M no ${HDD_PATH}/media/ plex.*
PrivateBin None (file) 30M / 128M yes -- paste.*
Radarr None (file) 150M / 512M yes ${HDD_PATH}/media/ radarr.*
Rallly PostgreSQL 50M / 256M yes -- poll.*
RomM MariaDB + Redis 300M / 1024M no ${HDD_PATH}/storage/romm/ arcade.*
Jellyseerr None (file) 100M / 384M yes -- requests.*
Sonarr None (file) 150M / 512M yes ${HDD_PATH}/media/ sonarr.*
SparkyFitness PostgreSQL 400M / 1792M no -- sparky.*
Tandoor Recipes PostgreSQL 150M / 512M yes -- recipes.*
Termix None (file) 30M / 128M yes -- terminal.*
Uptime Kuma None (file) 50M / 256M yes -- status.*
Vaultwarden None (SQLite) 50M / 256M yes -- vault.*
Vikunja None (file) 50M / 256M yes -- tasks.*
Wanderer None (file) 100M / 384M yes -- hike.*
wger SQLite 100M / 384M yes -- fitness.*
Wishlist None (file) 30M / 128M yes -- wishes.*
Zipline PostgreSQL 100M / 512M no -- img.*

Variable types per app

App DOMAIN HDD_PATH Secrets
ActualBudget yes -- --
AdventureLog yes -- SECRET_KEY, DB_PASSWORD
Audiobookshelf yes yes --
BentoPDF yes -- --
BookStack yes -- APP_KEY, DB_PASSWORD
Cal.com yes -- NEXTAUTH_SECRET, CALENDSO_ENCRYPTION_KEY, DB_PASSWORD
Calibre-Web Automated yes yes --
Claper yes -- SECRET_KEY_BASE, DB_PASSWORD
Code-Server yes -- PASSWORD
Crafty Controller yes -- --
Docmost yes -- APP_SECRET, DB_PASSWORD
Emby yes yes --
FileBrowser Quantum yes yes --
Ghost yes -- --
Gitea yes -- --
Glance yes -- --
Gokapi yes -- --
Grafana yes -- GF_SECURITY_ADMIN_PASSWORD
Gramps Web yes -- GRAMPSWEB_SECRET_KEY
Home Assistant yes -- --
Homebox yes -- --
Homepage yes -- --
Immich yes yes DB_PASSWORD
Jellyfin yes yes --
Kimai yes -- DB_PASSWORD, ADMIN_EMAIL, ADMIN_PASSWORD
Komga yes yes --
Mealie yes -- --
n8n yes -- N8N_ENCRYPTION_KEY
Navidrome yes yes --
Nextcloud yes yes DB_PASSWORD, MYSQL_ROOT_PASSWORD, NEXTCLOUD_ADMIN_USER, NEXTCLOUD_ADMIN_PASSWORD
OnlyOffice yes -- JWT_SECRET
OpenGist yes -- --
Outline yes -- SECRET_KEY, UTILS_SECRET, DB_PASSWORD
Paperless-ngx yes yes PAPERLESS_SECRET_KEY, DB_PASSWORD, PAPERLESS_ADMIN_USER, PAPERLESS_ADMIN_PASSWORD
Papra yes -- --
Plant-it yes -- JWT_SECRET
Plex yes yes PLEX_CLAIM
PrivateBin yes -- --
Radarr yes yes --
Rallly yes -- SECRET_PASSWORD, DB_PASSWORD
RomM yes yes DB_PASSWORD, MYSQL_ROOT_PASSWORD, ROMM_AUTH_SECRET_KEY
Jellyseerr yes -- --
Sonarr yes yes --
SparkyFitness yes -- DB_PASSWORD, APP_DB_PASSWORD, API_ENCRYPTION_KEY, BETTER_AUTH_SECRET
Tandoor Recipes yes -- SECRET_KEY, DB_PASSWORD
Termix yes -- --
Uptime Kuma yes -- --
Vaultwarden yes -- ADMIN_TOKEN
Vikunja yes -- VIKUNJA_SERVICE_JWTSECRET
Wanderer yes -- MEILI_MASTER_KEY
wger yes -- SECRET_KEY
Wishlist yes -- --
Zipline yes -- CORE_SECRET, DB_PASSWORD

Storage strategy

  • HDD host paths (${HDD_PATH}/storage/...): Large user data — photos, documents, ROMs
  • Named Docker volumes (on internal SSD): Databases, app config, caches — need fast I/O
  • Templates without ${HDD_PATH} work without an external HDD (e.g., ActualBudget, Mealie)

Docker Compose template standards

All templates follow these standards (enforced via audit):

  • ${DOMAIN} variable syntax for all domain references (not hardcoded)
  • deploy.resources.limits.memory on every service container
  • Healthchecks on every service (appropriate for service type)
  • restart: unless-stopped on every service
  • TZ=Europe/Budapest on every service
  • traefik-public external network + internal network for DB services
  • Explicit container_name: on every service
  • Header comment block with: app name, domain, DB type, RAM, Pi compatibility
  • depends_on with condition: service_healthy for DB/Redis dependencies

Legacy: Portainer-based deployments

The generate-customer.sh script and templates.json are kept for Portainer-only setups where the felhom-controller is not used. For controller-based deployments, these are not needed.

Legacy workflow

# Generate customer templates (on your workstation)
./scripts/generate-customer.sh --customer demo-felhom \
    --domain demo-felhom.eu --hdd-path /mnt/felhom-drives/hdd_1 --push

Adding a New App

  1. Create templates/<appname>/docker-compose.yml following the template standards above
  2. Create templates/<appname>/.felhom.yml following the metadata format
  3. Commit and push — the controller will pick it up on next sync
  4. (Legacy) If also needed for Portainer: add entry to templates.json and generate-customer.sh
Repository Purpose
app-catalog-felhom.eu This repo — templates + metadata
felhom-controller felhom-controller + deploy scripts
felhom.eu Website + k3s manifests
S
Description
No description provided
Readme 1.5 MiB
Languages
Python 81.9%
Shell 18.1%