bentopdf :latest -> v2.8.6; calibre-web :latest -> v4.0.6 (== running digest on demo 9201, c31a738b - pin is a no-op); papra :latest -> 26.6.1-rootless (latest was the rootless variant); recipe-importer :latest -> v0.9.11 (tag pre-existed, digest-equal, no retag needed); termix :latest -> 2.5.0. All five pins digest-identical to what :latest resolved to on 2026-07-12. New gate scripts/check-image-pins.py (catches floating tags AND untagged refs; red-proofed both shapes). Standing rule in CLAUDE.md + REUSE.md row.
23 KiB
Changelog
2026-07-12 — image pinning sweep: :latest eliminated from all templates (5 pins) + standing gate
A catalog sweep found 5/53 templates with unpinned images. Beyond version discipline, :latest
breaks restore fidelity: the controller's recovery-unit ImagePins pins the tag, so restoring a
:latest app re-pulls whatever :latest means at restore time — potentially schema-incompatible
with the data being restored. Rule applied: a deployed app pins to the digest it is RUNNING
(pin ≠ upgrade); undeployed apps pin to the verified upstream stable. All five pins are
digest-identical to what :latest resolved to on 2026-07-12 — a pure no-op for running apps.
| App | Old | New | Evidence |
|---|---|---|---|
| bentopdf | ghcr.io/alam00000/bentopdf:latest |
:v2.8.6 |
digest == latest (eaeea1e4…); undeployed |
| calibre-web | crocodilestick/calibre-web-automated:latest |
:v4.0.6 |
digest == RUNNING image on demo 9201 (c31a738b…) |
| papra | ghcr.io/papra-hq/papra:latest |
:26.6.1-rootless |
latest == the -rootless variant (a7a42e22…); -root differs — variant preserved |
| recipe-importer | gitea.dooplex.hu/admin/recipe-importer:latest |
:v0.9.11 |
tag pre-existed in registry, digest == latest (f3cb617c…) — no retag needed |
| termix | ghcr.io/lukegus/termix:latest |
:2.5.0 |
digest == latest == release-2.5.0 (4d337131…); undeployed |
- New rerunnable gate
scripts/check-image-pins.py: fails on:latest/dev/nightly/edge/main/masterAND on untagged image refs (implicit :latest);@sha256:digests count as pinned. Red-proofed both shapes (revert→exit 1→restore). - Standing rule added to
CLAUDE.md(never :latest / untagged; deployed apps pin to running digest). templates.jsoncarries no image strings (legacy metadata only) — untouched.- Fleet caveat: non-deployment of bentopdf/papra/termix verified on demo 9201 only; felhotest unreachable + Peti's box offline at sweep time (operator approved proceeding — pins are digest-equal to latest, so worst case equals the status quo).
2026-07-06 — healthcheck sweep: localhost → 127.0.0.1 across all 48 templates
Escalation of the re-run vaultwarden observation
(felhom.eu/documentation/audits/RERUN-p1p3-2026-07-06.md) from an instance to a class: 48/53
templates used localhost in their docker healthcheck test: line. BusyBox wget (and the node /
python / curl one-shot forms, incl. mealie's socket.create_connection) resolve localhost→IPv6
::1 with no cross-address-family fallback, so an IPv4-only-binding app reads docker-unhealthy
while fully serving. Mechanical sweep localhost→127.0.0.1, scoped strictly to the healthcheck
test: lines (diff-reviewed: no app env/config/label line changed; .felhom.yml files were already
clean). Industry practice — never localhost in container healthchecks. New REUSE.md convention row.
2026-07-06 — vaultwarden F1 fix: _ENABLE_SMTP boot-gate (campaign finding, pilot-blocking)
The no-mercy campaign (felhom.eu audits/CAMPAIGN-nomercy-2026-07-06.md, finding F1) proved that a
FRESH vaultwarden deploy with app-email off — the default state — crash-loops: the template always
defines SMTP_HOST=${SMTP_HOST:-} / SMTP_FROM=${SMTP_FROM:-}, and vaultwarden treats a
defined-but-EMPTY env var as "set", so its config validation (smtp_host.is_some() == smtp_from.is_empty()) errors out and the process exits. The old comment ("empty SMTP_HOST = mail
stays disabled") was wrong for this image. Empirically proven on the pinned
vaultwarden/server:1.33.2-alpine (probe P1: defined-empty pair → exact campaign error, exit 12;
P2: _ENABLE_SMTP=false + same empty pair → boots; P3: _ENABLE_SMTP=true + host+from → boots).
Fix: gate the whole SMTP group with vaultwarden's own _ENABLE_SMTP flag — compose default
false (validation skipped, mail off, clean boot), flipped to "true" by the app-email injection
via smtp_mapping.extra (no controller change needed — extra already rides smtpEnv). The ON
path is byte-identical to the previously send-tested state plus the flag.
Sweep note (no edits): the other five smtp-mapped templates (calcom, gitea, mealie, nextcloud,
rallly) are boot-proven tolerant of defined-empty mail env — all ran healthy as fresh email-off
deploys during the campaign; gitea's GITEA__mailer__SMTP_ADDR=${...:-} pattern likewise.
Vaultwarden was the only strict image. New REUSE.md trap row: strict images need an enable-flag
gated false in compose + "true" in smtp_mapping.extra; boot-prove fresh email-off deploys.
2026-07-03 — sparkyfitness FINALIZED + live-validated (both VERIFY markers resolved); REUSE probe-naming row
The first worked example of the new felhom-app-catalog skill (felhom.eu). Both
VERIFY-BEFORE-FINALIZE healthcheck guesses resolved by inspecting the real images on the demo box:
frontend (Alpine/nginx) HAS BusyBox wget → drafted wget --spider :80/ probe confirmed + kept;
server HAS node v24.17.0 → node-exec :3010/api/health probe confirmed (path proven live:
{"status":"UP"}). Frontend container_name renamed → sparkyfitness (= the stack name): the
controller-side probe dials the exact-name container, fallback is the FIRST prefix match (could be
the DB) — new REUSE.md §2 "Probe-container naming" row records the convention (verified in
felhom-controller healthprobe.go). Mem-sum comment added (512+1024+256 = 1792M, value unchanged).
Live-validated on demo via the real dashboard UI (sync + Frissítés): 3/3 containers healthy,
controller probe healthy: true (http :80 → 200), sparky.demo-felhom.eu 200 via Traefik;
data_key secrets untouched (server/db containers not recreated). Kept deployed.
2026-07-03 — docs: CLAUDE.md light expansion
The minimal REUSE-rollout stub expanded to a proper (still ~30-line) CLAUDE.md: what the repo is
(one dir per app, two template files, Hungarian customer text), the push-to-main = deploy contract
(controller sync ≤15 min / manual trigger), legacy templates.json warning, and pointers
(REUSE.md, README format spec, the felhom-build-deploy skill). No template changes.
2026-07-03 — docs: REUSE.md introduced
Cross-repo reuse-map rollout (docs-only). New REUSE.md: catalog conventions verified against all
53 apps — the canonical example app (paperless-ngx), .felhom.yml required fields, healthcheck
family per image type (BusyBox wget / curl / Node / Python / DB sidecars), memory-limit convention,
new-app checklist, and traps (gokapi entrypoint hack, legacy templates.json). Known README drift
recorded in §6 (NOT fixed). Also a minimal CLAUDE.md carrying the REUSE.md pointer + maintenance
rule (full CLAUDE.md is a separate task).
2026-06-29 — App-email: calcom + nextcloud (tls_mode=plaintext :2526 + nextcloud split-From)
- nextcloud —
smtp_mappingwithtls_mode: plaintext(controller injects port 2526, the plaintext-only listener) + split From (from_var=MAIL_FROM_ADDRESS+from_domain_var=MAIL_DOMAIN→ nextcloud@felhom.eu). Compose references the injected${SMTP_*}/${MAIL_*}. Live-confirmed: real password-reset delivered via plaintext :2526 (Symfony Mailer never attempted STARTTLS). - calcom —
smtp_mappingwithtls_mode: plaintext(EMAIL_SERVER_HOST/PORT, EMAIL_FROM=calcom@felhom.eu). Plus three pre-existing template fixes (calcom never deployed before — the image pin was invalid): (1) imagev4.8.7→v4.6.9(the pinned tag has no published image); (2) added requiredDATABASE_DIRECT_URL(Prismamigrate deployfails without it → incomplete schema → 500s); (3) healthcheck/api/health→/api/auth/providers(the old path 404s in v4.x → container stayed unhealthy → Traefik wouldn't route). - Both apps point at the controller's
:2526plaintext-only listener because their SMTP clients opportunistically STARTTLS-upgrade and can't skip the self-signed cert — the listener simply doesn't offer STARTTLS, so they stay plaintext (accepted on the single-tenant app bridge).
2026-06-29 — App-email rollout: gitea + rallly (calcom/nextcloud/immich = findings)
- gitea 1.23.4 — added
smtp_mapping(STARTTLS viaGITEA__mailer__PROTOCOL=smtp+starttls+FORCE_TRUST_SERVER_CERT=trueto trust the shim's self-signed cert; singleGITEA__mailer__FROM). Compose references the injectedGITEA__mailer__*keys; env applied every boot. - rallly — added
smtp_mapping(Nodemailer STARTTLS,SMTP_SECURE=false+SMTP_REJECT_UNAUTHORIZED=falseto accept the self-signed cert; singleNOREPLY_EMAIL). Also fixed three pre-existing template bugs that made rallly undeployable (never caught because the bad pin never ran): (1) image pin3.12.1doesn't exist →3.11.2; (2) healthcheck usedwget, absent from the rallly image (exit 127) → container unhealthy → Traefik wouldn't route it → replaced with a Node http check; (3) added requiredSUPPORT_EMAIL+ a validNOREPLY_EMAILdefault (rallly refuses to boot without them). - Both gitea and rallly send-tested live end-to-end (app → shim → hub → Resend): gitea password-reset
(From
gitea@felhom.eu) and rallly registration code (Fromrallly@felhom.eu) both delivered. - NOT wired — reported as findings (
felhom.eu/documentation/audits/FINDING-app-email-rollout-2026-06-29.md):- cal.com v4.8.7 — hard-codes TLS
rejectUnauthorized:truewith no override; opportunistic STARTTLS against the self-signed shim fails. Needs a non-STARTTLS-advertising plaintext listener (mechanism change). - nextcloud 31 — no cert-skip env (same opportunistic-STARTTLS gap) and a split From
(
MAIL_FROM_ADDRESS+MAIL_DOMAIN) the single-from_varmapping can't express. - immich v2.5.5 — no SMTP env vars at all; config is admin-UI/DB or an
IMMICH_CONFIG_FILEJSON. Does not fit env-injection; left for a future config-file-injection mechanism (or manual admin-UI setup).
- cal.com v4.8.7 — hard-codes TLS
2026-06-29 — App-email: smtp_mapping for Vaultwarden + Mealie
- Added the
smtp_mappingblock totemplates/vaultwarden/.felhom.ymlandtemplates/mealie/.felhom.yml, enabling managed outbound email (app → in-controller shim → hub → Resend) for the two spike-proven apps (SPIKE-smtp-app-relay-2026-06-28). The controller injectsSMTP_*at deploy/redeploy when app-email is on (global + per-app); the From address is<app>@felhom.eu. SMTP auth creds are intentionally left unset (the shim accepts no-auth on the Docker network).- Vaultwarden: STARTTLS (
SMTP_SECURITY=starttls) +SMTP_ACCEPT_INVALID_CERTS/HOSTNAMES=trueto accept the shim's self-signed cert. - Mealie: plaintext (
SMTP_AUTH_STRATEGY=NONE) on :2525 — Mealie has no accept-invalid-cert option, so STARTTLS to a self-signed shim would fail; plaintext to the Docker-network-only shim is the spike-validated mode.
- Vaultwarden: STARTTLS (
- Both
docker-compose.ymlfiles now reference the injected${SMTP_*}keys (with harmless defaults) so the values reach the container; emptySMTP_HOSTkeeps mail disabled when the toggle is off. - Documented the
smtp_mappingpattern inREADME.mdso further apps are easy adds.
2026-06-28 — Add SparkyFitness (v0.17.2) — nutrition/workout tracker
- New app
templates/sparkyfitness/{docker-compose.yml,.felhom.yml}: a self-hosted nutrition/calorie + workout/weight tracker (alternative to wger). Three containers — nginx frontend (SPA :80, the sole Traefik ingress, proxies/api+/uploadsinternally) + Node server (:3010) + dedicated postgres:15-alpine. Server + DB stay on the internal network with no Traefik labels. - Native email/password auth (no OIDC/Authentik — that's DooPlex-specific); subdomain
sparky(deliberately ≠ wger'sfitnessto avoid a Host() collision).pi_compatible: false,needs_hdd: false. - Two DB roles:
sparky(POSTGRES superuser, runs init/migrations) +sparkyapp(limited app role the server auto-creates on first boot) — separateDB_PASSWORD/APP_DB_PASSWORD.PGDATAin apgdatasubdir of the named volume. Four auto-generated,locked_after_deploysecrets;API_ENCRYPTION_KEY+BETTER_AUTH_SECRETcarrydata_key: true(restore recovers, never regenerates — both are 64-char hex). - Transcribed from the validated k3s manifest
homelab-manifests/workout-system/sparkyfitness.yaml(pinned image tags, two-DB-role model, never-change crypto keys,/api/health, pg15 + PGDATA subdir). - Image-probe findings (build server, v0.17.2): server keeps the
node -e/api/healthprobe (node present); frontend keeps thewget --spiderprobe (bothwgetandcurlpresent). No probe changes needed. - Live-validated on guest 9201 (controller v0.87.0): synced via "Sablonok frissítése"; deployed through
the real dashboard flow (Domain auto, Subdomain
sparky, 4 secrets auto-gen). All 3 containers healthy; server log shows clean migrations +sparkyapprole created + RLS applied, no crash loop, no uploads EACCES;GET /api/healththrough the public edge returns{"status":"UP"}; login/register page serves over a valid TLS cert athttps://sparky.demo-felhom.eu.
2026-06-26 — crafty-controller: image bump 4.4.8→4.10.7 + publish Java port range + connection guidance
- Image bump
crafty-4:4.4.8→4.10.7(latest stable; 4.10.8/4.11.0 don't exist in the registry). 6 minor versions of fixes incl. security CVEs. Java 25 verified present in 4.10.7 (/usr/lib/jvm/java-25-openjdk-amd64, defaultjava -version= openjdk 25.0.3; 8/11/17/21 also available) — so the latest-Minecraft (26.x, needs Java 25) blocker is resolved. Healthcheck + Traefik https-backend labels unchanged (Crafty still serves HTTPS on 8443). - Published the Java game-port range
25565-25575:25565-25575(TCP, 11 ports = up to 11 Java servers; first server 25565, rest 25566–25575). Nonetwork_mode: host(would break Traefik routing). Bedrock UDP 19132 intentionally out of scope. - App-page guidance (
.felhom.ymlfirst_steps + prerequisites): how to set the server port within 25565–25575, how to connect on the LAN (manual IP:port — "scan for LAN" won't auto-list), and that internet access needs operator port-forwarding. (Static text — can't show the live LAN IP.) - Live-verified on guest 9201: 4.10.7 healthy; public URL 302; the guest's bridged LAN IP
192.168.0.121reaches the real Crafty "test" server on25565(TCP OPEN + Minecraft SLP handshake returns JSON status);:25575reachable,:25600closed (negative control). In-place upgrade preserved the admin, the operator's configured MFA, and the test server. - Correction (earlier draft was wrong): an earlier note here claimed the upgrade "locked out the
admin (TOTP)." That was a misdiagnosis — the
totp_datarow + recovery codes were operator-configured MFA, so the 401 on a password-only login was correct behaviour, NOT an upgrade bug. There is no upgrade regression; the bump preserves data and MFA correctly.
2026-06-26 — crafty-controller: seed a felhom-generated admin password (replaces Crafty's ugly random one)
- crafty-controller: instead of reading Crafty's auto-generated (long, symbol-laden) random admin
password, we now seed a clean felhom-generated one — same pattern as gokapi, so initial passwords are
consistent across the catalog.
- Crafty's image ships
app/config_original/default.json = {"username":"admin","password":"crafty"}; "crafty" is 6 chars < Crafty's 8-char minimum, so Crafty rejected it and generated a random password. - New
CRAFTY_PASSWORDdeploy field (type: password,generate: password:24, locked after deploy — mirrors gokapi'sGOKAPI_PASSWORD). The compose entrypoint overwrites thedefault.jsontemplate with this password before the launcher runs; on fresh install Crafty creates theadminuser with it. initial_credentials.filerepointeddefault-creds.txt→default.json(same json/username/password keys), so the controller's app-page "Kezdeti belépési adatok" card shows the seeded password — the customer sees the same value at deploy time and on the app page.- Catalog-only change (reuses felhom-controller v0.84.0's initial_credentials reader + the gokapi-style seed). Requires a fresh install to take effect (the seed is only read on first run).
- Crafty's image ships
2026-06-26 — crafty-controller: surface the auto-generated initial admin password on the app page
- crafty-controller: Crafty writes a random admin password to
/crafty/app/config/default-creds.txtat first boot (its built-in default is rejected as "too short"). Customers had to read the container logs to find it. Added aninitial_credentialsblock (new general felhom-controller v0.84.0 mechanism):file+format: json+username_key/password_key+ anote. The controller reads the file live from the container and shows username + password (masked, reveal/copy) on the app's page under "Kezdeti belépési adatok". Requires felhom-controller ≥ v0.84.0. - Updated
first_stepsto point at the app page for the initial login instead of "find it in the logs".
2026-06-26 — crafty-controller: Traefik https backend + scoped skip-verify (fixes 502)
- crafty-controller: the healthcheck fix un-withheld the Traefik route, exposing a pre-existing
502 — Traefik proxied
http://…:8443to Crafty's HTTPS-only self-signed backend (Crafty serves no plain-HTTP panel;:8000only redirects). Added two service labels:loadbalancer.server.scheme=https— Traefik now speaks HTTPS to the backend.loadbalancer.serverstransport=insecure-skip-verify@file— references the named serversTransport defined in the controller-managed Traefik dynamic config (felhom-controller v0.83.0), which skips verifying Crafty's per-container self-signed cert. Verification stays ON for every other backend (scoped Option B; no globalinsecureSkipVerify). The@filesuffix is the cross-provider reference from the docker provider to the file-provider transport.- Requires felhom-controller ≥ v0.83.0 (which renders the
insecure-skip-verifytransport).port=8443and the router/tls labels are unchanged.
2026-06-26 — crafty-controller healthcheck fix (curl-absent + http-vs-TLS probe)
- crafty-controller: container was permanently
unhealthy→ route withheld (routeUnpublished). Two independent healthcheck root causes, both fixed in one change:- Docker healthcheck ran
curl -fk https://localhost:8443, but thecrafty-4:4.4.8image has nocurland nowget(exec: "curl": not found, FailingStreak 150). Replaced with a dependency-free python3 TLS-socket liveness probe (/usr/bin/python3is present): completes a TLS handshake to127.0.0.1:8443(unverified context mirrors the old-k; Crafty's cert is self-signed).start_period30s → 60s for cold-boot headroom (cert gen + migrations). - Controller-side probe (
.felhom.yml healthcheck.checks) wastype: httpagainst Crafty's TLS-only 8443 →probeHTTPsent plaintext HTTP, got a TLS record →HealthProbe.Healthy=false, whichmanager.gore-applies to override Docker's verdict back tounhealthy. Changedhttp→tcp(probeTCPdial succeeds against a TLS listener). Both layers had to change together. - Live-validated on guest 9201 (
demo-felhom): synced → recreated via the update path → DockerState.Health: healthy(ExitCode 0),health_probe.healthy: true(tcp :8443, 5ms), http-vs-TLS WARNs stopped, stable green 3+ min, Traefik now publishes the route (crafty-controller@docker). - Known follow-up (separate, out of this fix's scope): the public URL still returns 502 — a
distinct pre-existing bug the un-withheld route exposed: Traefik proxies
http://…:8443to Crafty's HTTPS-only backend. Needs a Traefik HTTPS-backend + self-signedserversTransport(insecureSkipVerify) in the controller-generated Traefik config — tracked separately.
- Docker healthcheck ran
2026-06-23 — gokapi: index redirect + admin username display
- gokapi: seed
RedirectUrlrepointed from Gokapi's GitHub default →https://${SUBDOMAIN}.${DOMAIN}/admin. Gokapi's bare root/redirects toRedirectUrl; the controller's "Megnyitás" link is always the bare subdomain root, so it was landing on Gokapi's GitHub instead of the app. Now/→/admin→ login. Applied to the live demo (config.json edit + restart) and the seed (future deploys). - gokapi: added
app_info.default_creds("Felhasználó: admin · jelszó a Beállítások oldalon") so the app-info page shows the initial admin user like other apps; fixedfirst_steps(no more setup wizard).
2026-06-23 — gokapi reproducible headless setup (fixes public "maintenance mode")
- gokapi: was stuck in "maintenance mode" on the public URL since first deploy — Gokapi's one-time
/setupwizard was never completed, and (verified against the docs + the v1.9.6 binary) no Gokapi version supports env-var headless setup for admin credentials. Worse, the unconfigured/setupwas publicly reachable = an unauthenticated admin-takeover window.- Fix: the compose
entrypointnow seeds aconfig.jsonon first boot (admin user, this app's public URLhttps://${SUBDOMAIN}.${DOMAIN}/, local storage, Encryption Level 0 so it restarts without a prompt) withPassword/SaltAdmin/SaltFilescleared, then runs Gokapi's documented--deployment-passwordone-shot to set the felhom-generated admin password before the server starts serving. The admin account is claimed at first boot →/setupis never exposed. .felhom.yml: newGOKAPI_PASSWORDdeploy field (type: password,generate: password:24, shown to the customer, locked after deploy). Admin username isadmin.- Seed is pinned to Gokapi v1.9.6 (
ConfigVersion 21) — re-capture the seed if the image is bumped. - Live-validated on guest 9201: fresh remove+redeploy → headless auto-config, public login works, no maintenance page, admin claimed at first boot (browser-verified login).
- Fix: the compose
2026-06-22 — gitea healthcheck fix (unattended test campaign)
- gitea: healthcheck probe repointed
/api/v1/version→/api/healthz(docker HC + controller.felhom.ymlprobe),start_period30s → 90s.- Surfaced during the Phase-2 deploy sweep: a fresh gitea reported
unhealthybecause/api/v1/versionreturns 404 until the install wizard / INSTALL_LOCK completes, while the container was serving fine on :3000 (/api/healthz→ 200). Same class as the komga fix.
- Surfaced during the Phase-2 deploy sweep: a fresh gitea reported
2026-06-22 — komga healthcheck fix (unattended test campaign)
- komga: healthcheck probe repointed
/api/v1/actuator/health→/actuator/health.- Root cause: komga's Spring Boot actuator endpoint is served unauthenticated at
/actuator/health(HTTP 200), while everything under the/api/v1prefix is auth-gated — so the old probe got HTTP 401,curl -fexited 22, and the container reportedunhealthydespite serving normally on :25600. Diagnosed live on guest 9201 (probe matrix:/,/actuator/health,/api/v1/oauth2/providers,/loginall 200;/api/v1/actuator/health→ 401). - The
gotson/komga:1.20.0image shipscurl(verified), so the probe tool is unchanged.
- Root cause: komga's Spring Boot actuator endpoint is served unauthenticated at