60 template directories, 58 offered. Records: onboarding/grocy.md and onboarding/lubelogger.md, all 61 checks answered, none open. Evidence: felhom.eu/documentation/audits/new-apps-2026-10-10/. Grocy 4.7.1 from lscr.io/linuxserver/grocy (grocy publishes no image of its own). SQLite in one volume, no HDD, ~30 MiB idle, amd64 + arm64, Hungarian UI 91.6 %. First admin class 3: it starts with its documented admin/admin and after_install replaces that password with a generated one. LubeLogger v1.7.3 from ghcr.io/hargata/lubelogger. LiteDB in one volume, no HDD, ~60 MiB idle, amd64 + arm64. First admin class 1 — and it has to be: the image ships EnableAuth=false, and with that the middleware mints a ticket with the IsRootUser role for every visitor. Measured on a default start: a stranger got 200 on /, on /api/vehicles and on /Home/Settings and CREATED A VEHICLE. The compose entrypoint exports EnableAuth=true and the SHA-256 of a generated name and password, so the app's own login is on before its first byte (at t+1 s nothing listening, at t+2 s /api/vehicles already 401). Two defects found by the walk and fixed before publishing: 1. An after_install command may not contain `$`. The controller runs every element through os.Expand and refuses one naming anything outside env:, so PHP cannot be inlined. On 9202 the first attempt came back `[pw argv dsn db i t e s n q h] not declared in env or has no value — not run` and the app sat behind its install hold with admin/admin in place. The code now lives in a file the compose entrypoint writes. Written into REUSE.md's after_install row as a trap. 2. Grocy's persisted config.php does not follow the image. The image copies config-dist.php only when that file is absent, so a volume written by 4.6.0 and started under 4.7.1 answered HTTP 500 on every page — AUTH_CLASS names a class 4.7 moved — while its log said migrations done. The entrypoint now deletes the file at every start. The 4.6.0 -> 4.7.1 edge failed before this and is proven after it, on both venues. Ladders, written by upgrade-test.py --write-ladder from both verdicts: grocy 4.6.0 -> 4.7.1 bench proven, box proven (guarded Update, 41 s) lubelogger v1.7.2 -> v1.7.3 bench proven, box proven (25.6 s) Checklist 6.3 came from a real failure, not a forced one: before the config.php fix the product undid the same step in 346 s with the data intact. REUSE.md: a fifth healthcheck family (bash /dev/tcp) for an image with no HTTP client at all, measured in both directions. Tool fixes made on the way: check-onboarding.py crashed on a Windows console while printing which ids were open; upgrade-test.py's read_text/write_text used the platform encoding and wrote a cp1250 em dash into a template full of Hungarian.
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).
The family gate (family_gate, controller ≥ 0.287.0)
09 §3 decisions 63/64: an app with no login of its own (MeTube), or one whose own login cannot be offered to
strangers (Grimmory's 15-minute lock for everyone, R-775), is published ONLY behind the household's family gate. A
stranger reaches nothing; a family member signs in on the box's own page with their own name and password (the
dashboard's Biztonság → Család card adds, resets and removes members — the password is shown once). The family
login never opens the dashboard.
family_gate: true
family_gate_except: # optional — paths a phone or e-reader app calls; each keeps the APP's own login
- "/api/v1/opds" # a LITERAL path prefix; the box anchors it: ^/api/v1/opds(/|$)
min_controller: "0.287.0" # REQUIRED with family_gate — an older box would publish the app OPEN
- An exception is a literal path prefix (letters, digits,
. _ ~ - /) — never a regex, a traefik matcher,..,//or/. The box anchors it at a path-segment boundary, so/api/v1/opdsxstays behind the gate (finding F1 of the spike,audits/permanent-gate-2026-10-01/). Measure every exception on 9202 as a stranger (the app's own refusal, 401) and a look-alike (<prefix>x, the gate's refusal). - No exception for an app with no login of its own — an exception would be an open door.
- The gate is recorded at install (
family_gate:inapp.yaml), so a catalog change never gates or un-gates an installed app; the exceptions follow the current template. - Gate:
scripts/check-family-gate.py(incatalog_gates.py) refuses a non-literal exception, an exception list without the gate,family_gatewithoutmin_controller≥ 0.287.0, andfamily_gatewhile the newest baked golden is older than 0.287.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 / 896M | yes | -- | travel.* |
| Audiobookshelf | None (file) | 100M / 512M | yes | ${HDD_PATH}/media/audiobooks/ |
audiobooks.* |
| BentoPDF | None (file) | 100M / 384M | yes | -- | pdf.* |
| BookStack | MariaDB | 150M / 768M | yes | -- | wiki.* |
| Cal.com | PostgreSQL | 200M / 1792M | no | -- | cal.* |
| Calibre-Web Automated | None (file) | 200M / 768M | no | ${HDD_PATH}/media/books/ |
books.* |
| Claper | PostgreSQL | 100M / 640M | yes | -- | present.* |
| Code-Server | None (file) | 200M / 1024M | no | -- | code.* |
| Crafty Controller | None (file) | 256M / 2048M | no | -- | minecraft.* |
| Dawarich | PostgreSQL 17 (PostGIS) + Redis | 500M / 2688M | no | -- | timeline.* |
| 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.* |
| Grocy | None (SQLite) | 60M / 384M | yes | -- | kamra.* |
| Grimmory | MariaDB | 600M / 1408M | yes | ${USERDATA_PATH}/media/grimmory/ |
library.* |
| 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.* |
| Karakeep | SQLite + Meilisearch + Chrome | 700M / 2816M | no | -- | bookmarks.* |
| Kimai | MariaDB | 100M / 640M | yes | -- | time.* |
| Komga | None (file) | 200M / 512M | yes | ${HDD_PATH}/media/comics/ |
comics.* |
| LubeLogger | None (LiteDB) | 80M / 512M | yes | -- | garazs.* |
| Mealie | None (SQLite) | 200M / 1000M | yes | -- | recipes.* |
| MeTube | None (file) | 128M / 768M | yes | ${USERDATA_PATH}/media/metube/ |
video.* |
| n8n | None (file) | 150M / 512M | no | -- | auto.* |
| Navidrome | None (file) | 50M / 256M | yes | ${HDD_PATH}/media/music/ |
music.* |
| Nextcloud | MariaDB + Redis | 256M / 1664M | no | ${HDD_PATH}/storage/nextcloud/ |
cloud.* |
| OnlyOffice | None (file) | 512M / 2048M | no | -- | office.* |
| OpenGist | None (file) | 30M / 128M | yes | -- | gist.* |
| Outline | PostgreSQL + Redis | 200M / 1152M | no | -- | kb.* |
| Paperless-ngx | PostgreSQL + Redis | 500M / 1152M | yes | ${HDD_PATH}/storage/paperless/ |
paperless.* |
| Papra | None (file) | 256M / 768M | 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.* |
| Radicale | None (file) | 40M / 128M | yes | -- | calendar.* |
| 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 / 768M | 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 | -- | -- |
| Dawarich | yes | -- | SECRET_KEY_BASE (data_key), DB_PASSWORD, ADMIN_PASSWORD (password) |
| 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 |
| Grocy | yes | -- | ADMIN_PASSWORD (password) |
| Home Assistant | yes | -- | -- |
| Homebox | yes | -- | -- |
| Homepage | yes | -- | -- |
| Immich | yes | yes | DB_PASSWORD |
| Jellyfin | yes | yes | -- |
| Karakeep | yes | -- | NEXTAUTH_SECRET, MEILI_MASTER_KEY, OPENAI_API_KEY (optional, secret_input) |
| Kimai | yes | -- | DB_PASSWORD, ADMIN_EMAIL, ADMIN_PASSWORD |
| Komga | yes | yes | -- |
| LubeLogger | yes | -- | ROOT_PASSWORD (password) |
| 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 | -- |
| Radicale | yes | -- | RADICALE_PASSWORD (password) |
| 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
- Copy
onboarding/_TEMPLATE.mdtoonboarding/<appname>.mdand answer every check inNEW-APP-CHECKLIST.md(done with evidence / n/a with a reason). Theonboardinggate (scripts/check-onboarding.py, run bycatalog_gates.py --fast) refuses a new template directory without a complete record; publish both in one commit. - 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 |