mealie locks the ACCOUNT after 5 wrong logins and its login names (admin, changeme@example.com) are public, so a stranger could lock the household out for a day. Measured on 9202 with SECURITY_USER_LOCKOUT_TIME=1: the right password answered 423 for 120 min (the hourly job lifts it after the hour), then 200; a wrong one still 401. Evidence: felhom.eu/documentation/audits/rulings-2026-10-01/C/. CHANGELOG also records today's re-test run and fixes. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
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
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):
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):
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.
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 = <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 |