# 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//` to `/opt/docker/stacks//` 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) ```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 ```yaml 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 ``` ### Known default login (`after_install`, controller ≥ 0.279.0) An app that starts with a known admin login replaces it right after a FRESH install (`09` §3 decision 45): ```yaml deploy_fields: - env_var: ADMIN_PASSWORD # shown on the app page as the first password type: password generate: "password:24" locked_after_deploy: true after_install: service: bookstack # the app's own container env: [ADMIN_PASSWORD] # only these deploy values are filled into ${NAME} command: ["php", "/app/www/artisan", "bookstack:create-admin", "--email=admin@admin.com", "--name=Admin", "--password=${ADMIN_PASSWORD}", "--initial"] success: "The default admin user has been updated" # the output must carry this ``` Never run after a restore or a kept-data load. Keep `app_info.default_creds`: the page hides it once the command succeeded, and says "This app starts with a known, shared password…" while it is still in effect. Per-app status: `FIRST-ADMIN.md`. **Never paste `${ADMIN_PASSWORD}` into program code** (a `python3 -c` string, a Django `shell -c`, an Elixir `rpc` string): a quote in a household-typed password breaks or changes the program. Pass it as its OWN argument (`["python3", "-c", "...sys.argv[1]...", "${ADMIN_PASSWORD}"]`, mealie and wger) or inside a plain argument (`--password=${ADMIN_PASSWORD}`, calibre-web's `admin:${ADMIN_PASSWORD}`). An app whose policy demands a special character uses `generate: "password:24:special"` (controller ≥ 0.280.0: a lower, an upper, a digit and one of `-_.!@#%+=`). ### The setup gate (`setup_gate`, controller ≥ 0.280.0) An app whose FIRST VISITOR creates the admin is installed closed to everyone but the household (a browser signed in to the dashboard) until its first setup is done (`09` §3 decision 46): ```yaml setup_gate: true setup_done_probe: # optional — without it the household presses "Done, I set it up" url: http://n8n:5678/rest/settings # the app's own read-only status, on the docker network field: data.userManagement.showSetupOnFirstLoad # dotted JSON path done: "false" # its value once an admin exists, as text ``` The url uses the app's `container_name` and its internal port. **Measure the probe on 9202 before and after the setup** (it must flip), and prove a stranger gets the gate page / 401 until then. Per-app status: `FIRST-ADMIN.md`. A probe must answer **HTTP 200 with a JSON object** on both sides of the setup (the box reads only that; a 403/405 or a list never opens the gate, and it also blocks the household's press — gramps-web, ghost, home-assistant). ### Open sign-up after the first admin (`signup_block`, controller ≥ 0.281.0) `09` §3 decision 47: once the gate opens, a stranger cannot make an account. Where the app itself keeps sign-up open, the template names its sign-up address as a traefik matcher; the box answers it with "sign-up is closed" from then on, and the household can open it for 15 minutes from the app page. `add_people:` (hu, + `i18n.en`) tells them how. ```yaml signup_block: "PathPrefix(`/-/register`)" # opengist app_info: add_people: "Nyisd meg a regisztrációt 15 percre, és a családtagod regisztrál." ``` **Measure it on 9202:** after the setup a stranger's sign-up succeeds (the reason for the block), with the block it is refused, and the rest of the app still answers. **Two locks since controller 0.282.0.** Where the app has its OWN sign-up switch, wire it to `SIGNUP_CLOSED` / `SIGNUP_OPEN` with an OPEN default in the compose (`- DISABLE_REGISTRATION=${SIGNUP_CLOSED:-false}`) — so an app installed before is unchanged by the catalog — and declare `after_setup: {env: {SIGNUP_CLOSED: "true"}}`. The box sets it when the gate opens (or on "close sign-up now") and starts the app once. Measure whether the switch also blocks the household's own first account (it does on 6 of 9); `after_setup` avoids that either way. **Write the block case-insensitive and slash-tolerant:** `PathRegexp(`(?i)^/+api/+v1/+register`)`. Measured 2026-09-29: termix's router ignores case (`/users/CREATE` reached its sign-up), and PocketBase takes a collection name in any case and by its id. Test every block with `felhom.eu/documentation/audits/signup-lock-2026-09-29/B/tricks.py`. **`setup_done_probe:` must carry its measurement** in the comment directly above it — "measured", a date and both answers (`false -> true`, or "before … after …"). Gate: `scripts/check-probe-measured.py`. A probe may index a list (`setup.0.status`) and may count one non-200 status as done (`done_status: 405`, controller ≥ 0.282.0). ### 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` = `@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) → `@felhom.eu` | | `extra` | fixed extra env (e.g. accept-invalid-cert flags) | ```yaml # 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 ```bash # 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//docker-compose.yml` following the template standards above 2. Create `templates//.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` ## Related Repositories | Repository | Purpose | |------------|---------| | [app-catalog-felhom.eu](https://gitea.dooplex.hu/admin/app-catalog-felhom.eu) | This repo — templates + metadata | | [felhom-controller](https://gitea.dooplex.hu/admin/felhom-controller) | felhom-controller + deploy scripts | | [felhom.eu](https://gitea.dooplex.hu/admin/felhom.eu) | Website + k3s manifests |