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/
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:
- Controller periodically pulls this repo (configurable interval, default 15m)
- Copies
docker-compose.ymland.felhom.ymlfromtemplates/<app>/to/opt/docker/stacks/<app>/ - Never overwrites
app.yaml(deployed config/secrets) or.envfiles - Only copies files if content has actually changed (SHA-256 comparison)
- After sync, triggers a stack rescan so new/updated apps appear on the dashboard
- 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") on2525— 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.memoryon every service container- Healthchecks on every service (appropriate for service type)
restart: unless-stoppedon every serviceTZ=Europe/Budapeston every servicetraefik-publicexternal 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_onwithcondition: service_healthyfor 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
- Create
templates/<appname>/docker-compose.ymlfollowing the template standards above - Create
templates/<appname>/.felhom.ymlfollowing the metadata format - Commit and push — the controller will pick it up on next sync
- (Legacy) If also needed for Portainer: add entry to
templates.jsonandgenerate-customer.sh
Related Repositories
| Repository | Purpose |
|---|---|
| app-catalog-felhom.eu | This repo — templates + metadata |
| felhom-controller | felhom-controller + deploy scripts |
| felhom.eu | Website + k3s manifests |