# Changelog ## 2026-07-14 — backup classification `backup:` blocks for the 13 bind-bearing apps (controller v0.132.0) Adds the referential-coupling `backup:` classification block to every catalog app that binds `${HDD_PATH}`/`${USERDATA_PATH}` (13 apps: immich, paperless-ngx, nextcloud, calibre-web, audiobookshelf, komga, navidrome, radarr, sonarr, emby, jellyfin, plex, romm). Each block lists its `userdata:`/`hdd:` binds with a `class ∈ {mandatory, optional, excluded}` (COUPLED / DECOUPLED-precious / DECOUPLED-bulk); classes are operator-ruled (Viktor, 2026-07-14) + spike SQ2. Requires **controller v0.132.0**, which parses + validates these blocks (Task 2 of the backup-classification-redesign arc, `felhom.eu/documentation/audits/SPIKE-backup-classification-2026-07-14.md`). The classification is **INERT** — no backup tier changes behavior yet; Task 3 (tier policy engine) and Task 4 (manual `.fab` UI) consume it. All 13 blocks were verified against the shipped controller parser: parse-clean, every bind resolves `explicit` to its ruled class (zero validation errors). Notes: audiobookshelf `media/audiobooks` = **optional** (consistency with komga/romm curated media; the spike proposed excluded — PENDING a Viktor veto). radarr/sonarr `downloads` = excluded (transient cross-app queue). emby/jellyfin/plex `media` = excluded (`:ro` readers; state in volumes). The 42 volume-only apps get no block (classification moot — state rides in the recovery unit's volume dumps). ## 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`/`master` AND 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.json` carries 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_mapping` with `tls_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_mapping` with `tls_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) image `v4.8.7`→`v4.6.9` (the pinned tag has no published image); (2) added required `DATABASE_DIRECT_URL` (Prisma `migrate deploy` fails 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 `:2526` plaintext-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 via `GITEA__mailer__PROTOCOL=smtp+starttls` + `FORCE_TRUST_SERVER_CERT=true` to trust the shim's self-signed cert; single `GITEA__mailer__FROM`). Compose references the injected `GITEA__mailer__*` keys; env applied every boot. - **rallly** — added `smtp_mapping` (Nodemailer STARTTLS, `SMTP_SECURE=false` + `SMTP_REJECT_UNAUTHORIZED=false` to accept the self-signed cert; single `NOREPLY_EMAIL`). **Also fixed three pre-existing template bugs** that made rallly undeployable (never caught because the bad pin never ran): (1) image pin `3.12.1` doesn't exist → `3.11.2`; (2) healthcheck used `wget`, absent from the rallly image (exit 127) → container unhealthy → **Traefik wouldn't route it** → replaced with a Node http check; (3) added required `SUPPORT_EMAIL` + a valid `NOREPLY_EMAIL` default (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 (From `rallly@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:true` with 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_var` mapping can't express. - **immich v2.5.5** — no SMTP env vars at all; config is admin-UI/DB or an `IMMICH_CONFIG_FILE` JSON. Does not fit env-injection; left for a future config-file-injection mechanism (or manual admin-UI setup). ## 2026-06-29 — App-email: smtp_mapping for Vaultwarden + Mealie - Added the `smtp_mapping` block to `templates/vaultwarden/.felhom.yml` and `templates/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 injects `SMTP_*` at deploy/redeploy when app-email is on (global + per-app); the From address is `@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=true` to 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. - Both `docker-compose.yml` files now reference the injected `${SMTP_*}` keys (with harmless defaults) so the values reach the container; empty `SMTP_HOST` keeps mail disabled when the toggle is off. - Documented the `smtp_mapping` pattern in `README.md` so 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`+`/uploads` internally) + 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's `fitness` to 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) — separate `DB_PASSWORD`/`APP_DB_PASSWORD`. `PGDATA` in a `pgdata` subdir of the named volume. Four auto-generated, `locked_after_deploy` secrets; `API_ENCRYPTION_KEY` + `BETTER_AUTH_SECRET` carry `data_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/health` probe (node present); frontend keeps the `wget --spider` probe (both `wget` and `curl` present). 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 + `sparkyapp` role created + RLS applied, no crash loop, no uploads EACCES; `GET /api/health` through the public edge returns `{"status":"UP"}`; login/register page serves over a valid TLS cert at `https://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`, default `java -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). No `network_mode: host` (would break Traefik routing). Bedrock UDP 19132 intentionally out of scope. - **App-page guidance** (`.felhom.yml` first_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.121` reaches the real Crafty "test" server on `25565` (TCP OPEN + Minecraft SLP handshake returns JSON status); `:25575` reachable, `:25600` closed (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_data` row + 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_PASSWORD` deploy field (`type: password`, `generate: password:24`, locked after deploy — mirrors gokapi's `GOKAPI_PASSWORD`). The compose **entrypoint** overwrites the `default.json` template with this password before the launcher runs; on fresh install Crafty creates the `admin` user with it. - `initial_credentials.file` repointed `default-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). ## 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.txt` at first boot (its built-in default is rejected as "too short"). Customers had to read the container logs to find it. Added an `initial_credentials` block (new general felhom-controller v0.84.0 mechanism): `file` + `format: json` + `username_key`/`password_key` + a `note`. 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_steps` to 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://…:8443` to Crafty's **HTTPS-only** self-signed backend (Crafty serves no plain-HTTP panel; `:8000` only 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 global `insecureSkipVerify`). The `@file` suffix 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-verify` transport). `port=8443` and 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 the `crafty-4:4.4.8` image has **no `curl` and no `wget`** (`exec: "curl": not found`, FailingStreak 150). Replaced with a dependency-free **python3 TLS-socket** liveness probe (`/usr/bin/python3` is present): completes a TLS handshake to `127.0.0.1:8443` (unverified context mirrors the old `-k`; Crafty's cert is self-signed). `start_period` 30s → 60s for cold-boot headroom (cert gen + migrations). - **Controller-side probe** (`.felhom.yml healthcheck.checks`) was `type: http` against Crafty's **TLS-only** 8443 → `probeHTTP` sent plaintext HTTP, got a TLS record → `HealthProbe.Healthy=false`, which `manager.go` re-applies to override Docker's verdict back to `unhealthy`. Changed `http` → `tcp` (`probeTCP` dial succeeds against a TLS listener). Both layers had to change together. - Live-validated on guest 9201 (`demo-felhom`): synced → recreated via the update path → Docker `State.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://…:8443` to Crafty's HTTPS-only backend. Needs a Traefik HTTPS-backend + self-signed `serversTransport` (`insecureSkipVerify`) in the controller-generated Traefik config — tracked separately. ## 2026-06-23 — gokapi: index redirect + admin username display - **gokapi**: seed `RedirectUrl` repointed from Gokapi's GitHub default → `https://${SUBDOMAIN}.${DOMAIN}/admin`. Gokapi's bare root `/` redirects to `RedirectUrl`; 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; fixed `first_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 `/setup` wizard 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 `/setup` was publicly reachable = an unauthenticated admin-takeover window. - Fix: the compose `entrypoint` now seeds a `config.json` on first boot (admin user, this app's public URL `https://${SUBDOMAIN}.${DOMAIN}/`, local storage, Encryption Level 0 so it restarts without a prompt) with `Password`/`SaltAdmin`/`SaltFiles` cleared, then runs Gokapi's documented `--deployment-password` one-shot to set the **felhom-generated** admin password **before** the server starts serving. The admin account is claimed at first boot → `/setup` is never exposed. - `.felhom.yml`: new `GOKAPI_PASSWORD` deploy field (`type: password`, `generate: password:24`, shown to the customer, locked after deploy). Admin username is `admin`. - 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). ## 2026-06-22 — gitea healthcheck fix (unattended test campaign) - **gitea**: healthcheck probe repointed `/api/v1/version` → `/api/healthz` (docker HC + controller `.felhom.yml` probe), `start_period` 30s → 90s. - Surfaced during the Phase-2 deploy sweep: a fresh gitea reported `unhealthy` because `/api/v1/version` returns 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. ## 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/v1` prefix is auth-gated — so the old probe got HTTP 401, `curl -f` exited 22, and the container reported `unhealthy` despite serving normally on :25600. Diagnosed live on guest 9201 (probe matrix: `/`, `/actuator/health`, `/api/v1/oauth2/providers`, `/login` all 200; `/api/v1/actuator/health` → 401). - The `gotson/komga:1.20.0` image ships `curl` (verified), so the probe tool is unchanged.