From df9ddb4ac0d287e2b2b3e85da71851f2f2fc7aac Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Thu, 9 Jul 2026 10:02:29 +0200 Subject: [PATCH 1/6] fix: give api-docs liveness/readiness probes instead of a bare tcp check api-docs was the only service without a health: path, so it fell back to a TCP-only readiness probe with no liveness/startup probe at all -- a hung-but-alive container never got restarted. swagger-ui serves an HTML root, so / works the same way every other service's health check does. --- infra/helm/team-devoops/values.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml index 1262e91..a5f06ce 100644 --- a/infra/helm/team-devoops/values.yaml +++ b/infra/helm/team-devoops/values.yaml @@ -413,6 +413,7 @@ services: path: /docs port: 8080 db: false + health: / stripPrefix: false open: true resources: From d76782064353cb30e667a19886fb38e7fbaf393b Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Thu, 9 Jul 2026 10:07:02 +0200 Subject: [PATCH 2/6] feat: add per-service HorizontalPodAutoscaler to the helm chart Enables CPU-based autoscaling (min 1 / max 2) for all 9 app services -- metrics-server is already live on the cluster and every service already declares resources.requests, so HPA is viable with no other prerequisites. Postgres, Keycloak, Ollama and the monitoring stack keep their own templates and stay fixed-replica; they're stateful/singleton and shouldn't scale out. deployment.yaml now omits spec.replicas for autoscaled services -- otherwise every helm upgrade (which runs on every push to main) would reset it back to 1 and fight the HPA's own scaling decisions. The namespace ResourceQuota is nearly fully committed already (verified live: ~90%+ of both limits.cpu and limits.memory used just running one replica of everything), so in practice most scale-up attempts will sit Pending rather than actually schedule -- documented in the helm README along with how to tell the difference between Pending-on-quota and metrics-server not being reachable. --- infra/helm/README.md | 36 +++++++++++++++ .../team-devoops/templates/deployment.yaml | 4 ++ infra/helm/team-devoops/templates/hpa.yaml | 25 +++++++++++ infra/helm/team-devoops/values.yaml | 45 +++++++++++++++++++ 4 files changed, 110 insertions(+) create mode 100644 infra/helm/team-devoops/templates/hpa.yaml diff --git a/infra/helm/README.md b/infra/helm/README.md index 3c497af..1be5402 100644 --- a/infra/helm/README.md +++ b/infra/helm/README.md @@ -14,6 +14,8 @@ infra/helm/team-devoops/ _helpers.tpl # naming/label/image helpers deployment.yaml # generic Deployment rendered per service service.yaml # generic ClusterIP Service rendered per service + hpa.yaml # generic HorizontalPodAutoscaler, one per service + # with autoscaling.enabled -- see "Autoscaling" below ingress.yaml # nginx ingress (prefix-strip + plain rules) configmap-db.yaml # SPRING_DATASOURCE_URL/USERNAME secret-db.yaml # SPRING_DATASOURCE_PASSWORD / POSTGRES_PASSWORD @@ -225,6 +227,40 @@ kubectl -n ge83mom-devops26 get pods | grep ollama kubectl -n ge83mom-devops26 exec deploy/ollama -- ollama list ``` +## Autoscaling & self-healing + +Every app service (the 6 Spring services, `py-genai-helper`, `web-client`, +`api-docs`) gets a `HorizontalPodAutoscaler` (`templates/hpa.yaml`), driven by +CPU utilization against each service's `resources.requests.cpu` — the +in-cluster metrics-server (`v1beta1.metrics.k8s.io`) is already installed and +working on this cluster. Config lives per-service under `autoscaling:` in +`values.yaml` (`enabled`, `minReplicas`, `maxReplicas`, +`targetCPUUtilizationPercentage`); Postgres, Keycloak, Ollama and the +monitoring stack are rendered from their own templates and intentionally have +no HPA — they're stateful or singleton and shouldn't scale out. + +When a service has `autoscaling.enabled: true`, `templates/deployment.yaml` +omits `spec.replicas` entirely instead of pinning it to `1` — pinning it would +make every `helm upgrade` (which runs on every push to `main`) reset the +replica count and fight the HPA's own scaling decisions. + +**Quota caveat:** the namespace's `ResourceQuota` (`limits.cpu: 4`, +`limits.memory: 6Gi`) is committed almost in full just running one replica of +everything (`kubectl get resourcequota -n ge83mom-devops26` typically shows +~90%+ used on both). An HPA scale-up that the quota can't fit simply leaves +the extra pod `Pending` — it does not affect the already-running replica or +any other service. `kubectl get hpa -n ge83mom-devops26` shows current +`TARGETS`/replica counts; `` in the `TARGETS` column means +metrics-server isn't being reached for that resource, not that it's idle. + +Self-healing beyond autoscaling is native to Kubernetes and needs no extra +component here: every service with a `health:` path gets startup, readiness, +and liveness probes (`templates/deployment.yaml`), so the kubelet restarts a +container that stops responding, and the ReplicaSet controller replaces any +pod that's deleted or evicted. `helm upgrade --rollback-on-failure` (used by +the `deploy-k8s` pipeline job) adds deployment-level self-healing on top — +a rollout that never becomes healthy is rolled back automatically. + ## Manual deploy ```bash diff --git a/infra/helm/team-devoops/templates/deployment.yaml b/infra/helm/team-devoops/templates/deployment.yaml index 5d66f64..60bdec0 100644 --- a/infra/helm/team-devoops/templates/deployment.yaml +++ b/infra/helm/team-devoops/templates/deployment.yaml @@ -7,7 +7,11 @@ metadata: labels: {{- include "team-devoops.labels" (dict "name" $name "root" $root) | nindent 4 }} spec: + {{- /* Omitted when HPA-managed: setting it here would make every `helm upgrade` + reset replicas back to the default and fight the HPA's scaling decisions. */}} + {{- if not (and $svc.autoscaling $svc.autoscaling.enabled) }} replicas: {{ $svc.replicas | default 1 }} + {{- end }} progressDeadlineSeconds: {{ $svc.progressDeadlineSeconds | default 600 }} strategy: {{- toYaml $root.Values.strategy | nindent 4 }} diff --git a/infra/helm/team-devoops/templates/hpa.yaml b/infra/helm/team-devoops/templates/hpa.yaml new file mode 100644 index 0000000..1c4ec3c --- /dev/null +++ b/infra/helm/team-devoops/templates/hpa.yaml @@ -0,0 +1,25 @@ +{{- range $name, $svc := .Values.services }} +{{- if and $svc.autoscaling $svc.autoscaling.enabled }} +apiVersion: autoscaling/v2 +kind: HorizontalPodAutoscaler +metadata: + name: {{ $name }} + labels: + {{- include "team-devoops.labels" (dict "name" $name "root" $) | nindent 4 }} +spec: + scaleTargetRef: + apiVersion: apps/v1 + kind: Deployment + name: {{ $name }} + minReplicas: {{ $svc.autoscaling.minReplicas }} + maxReplicas: {{ $svc.autoscaling.maxReplicas }} + metrics: + - type: Resource + resource: + name: cpu + target: + type: Utilization + averageUtilization: {{ $svc.autoscaling.targetCPUUtilizationPercentage }} +--- +{{- end }} +{{- end }} diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml index a5f06ce..6ed8d16 100644 --- a/infra/helm/team-devoops/values.yaml +++ b/infra/helm/team-devoops/values.yaml @@ -282,6 +282,11 @@ services: dbUser: organization health: /actuator/health stripPrefix: true + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 env: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs" @@ -301,6 +306,11 @@ services: dbUser: member health: /actuator/health stripPrefix: true + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 env: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs" @@ -315,6 +325,11 @@ services: dbUser: event health: /actuator/health stripPrefix: true + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 env: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs" @@ -326,6 +341,11 @@ services: dbUser: feedback health: /actuator/health stripPrefix: true + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 env: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs" @@ -337,6 +357,11 @@ services: dbUser: finance health: /actuator/health stripPrefix: true + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 env: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "http://keycloak:8080/auth/realms/devops/protocol/openid-connect/certs" @@ -348,6 +373,11 @@ services: dbUser: letter health: /actuator/health stripPrefix: true + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 envFromSecret: letter-env env: SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" @@ -364,6 +394,11 @@ services: # generated controllers which keep their own name in the path -- needs the # whole "/api/v1/helper" prefix removed, not just "/api/v1". fullStrip: true + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 envFromSecret: genai-env env: KEYCLOAK_ISSUER_URL: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" @@ -401,6 +436,11 @@ services: db: false health: / stripPrefix: false + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 resources: requests: cpu: 50m @@ -416,6 +456,11 @@ services: health: / stripPrefix: false open: true + autoscaling: + enabled: true + minReplicas: 1 + maxReplicas: 2 + targetCPUUtilizationPercentage: 75 resources: requests: cpu: 50m From 4ec3b44e41df73e8ec66b709c83c70b123f63a31 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Thu, 9 Jul 2026 10:09:23 +0200 Subject: [PATCH 3/6] feat: tune rolling update strategy for zero-downtime on cheap services strategy: is now overridable per service (same pattern as resources:). web-client and api-docs (80Mi/80m each) get maxSurge: 1 / maxUnavailable: 0 -- cheap enough to fit the namespace's remaining quota slack, so these two get a genuine zero-downtime rolling deploy. Every other service keeps the existing maxSurge: 0 default, since surging a second replica of any of them would exceed the namespace's CPU quota and break the rollout. Also adds a preStop sleep hook + explicit terminationGracePeriodSeconds to every service's pod template, at no extra resource cost: it gives the endpoints controller time to drain a pod out of Service routing before SIGTERM, shrinking the request-drop window that maxSurge: 0 rollouts otherwise have for the rest of the services (old pod is killed before readiness on the new one even starts). --- .../helm/team-devoops/templates/deployment.yaml | 11 ++++++++++- infra/helm/team-devoops/values.yaml | 17 +++++++++++++++++ 2 files changed, 27 insertions(+), 1 deletion(-) diff --git a/infra/helm/team-devoops/templates/deployment.yaml b/infra/helm/team-devoops/templates/deployment.yaml index 60bdec0..60e7e9d 100644 --- a/infra/helm/team-devoops/templates/deployment.yaml +++ b/infra/helm/team-devoops/templates/deployment.yaml @@ -14,7 +14,7 @@ spec: {{- end }} progressDeadlineSeconds: {{ $svc.progressDeadlineSeconds | default 600 }} strategy: - {{- toYaml $root.Values.strategy | nindent 4 }} + {{- toYaml ($svc.strategy | default $root.Values.strategy) | nindent 4 }} selector: matchLabels: {{- include "team-devoops.selectorLabels" (dict "name" $name) | nindent 6 }} @@ -31,12 +31,21 @@ spec: imagePullSecrets: {{- toYaml . | nindent 8 }} {{- end }} + terminationGracePeriodSeconds: {{ $svc.terminationGracePeriodSeconds | default 30 }} containers: - name: {{ $name }} image: {{ include "team-devoops.image" (dict "name" $name "svc" $svc "root" $root) }} imagePullPolicy: {{ $root.Values.global.imagePullPolicy }} ports: - containerPort: {{ $svc.port }} + {{- /* Gives the endpoints controller time to remove this pod from Service + routing before SIGTERM, so in-flight requests aren't dropped mid-rollout -- + matters most for maxUnavailable-driven rollouts, which briefly have zero + healthy pods for a service otherwise. */}} + lifecycle: + preStop: + exec: + command: ["sh", "-c", "sleep 5"] {{- if $svc.persistence }} volumeMounts: - name: data diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml index 6ed8d16..953a878 100644 --- a/infra/helm/team-devoops/values.yaml +++ b/infra/helm/team-devoops/values.yaml @@ -241,6 +241,8 @@ forwardAuth: # Rolling update strategy — maxSurge: 0 ensures the old pod is terminated before # scheduling the new one, which is required to stay within the namespace CPU quota. +# Overridable per service via services..strategy (see web-client/api-docs below +# for the two services cheap enough to afford a real maxSurge: 1 zero-downtime deploy). strategy: type: RollingUpdate rollingUpdate: @@ -441,6 +443,15 @@ services: minReplicas: 1 maxReplicas: 2 targetCPUUtilizationPercentage: 75 + # Overrides the quota-driven global maxSurge: 0 (see .Values.strategy below) -- at + # 80Mi/80m limits this is cheap enough to actually surge an extra pod within the + # namespace's remaining quota slack, giving this service a genuine zero-downtime + # rolling deploy instead of the brief every-other-service gap maxSurge: 0 causes. + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 resources: requests: cpu: 50m @@ -461,6 +472,12 @@ services: minReplicas: 1 maxReplicas: 2 targetCPUUtilizationPercentage: 75 + # See web-client above -- same reasoning, same headroom. + strategy: + type: RollingUpdate + rollingUpdate: + maxSurge: 1 + maxUnavailable: 0 resources: requests: cpu: 50m From 3df1b0c55e79d70e46bd127e364f6dae2422712f Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Thu, 9 Jul 2026 10:44:13 +0200 Subject: [PATCH 4/6] fix: default local dev to the real backend instead of mocks The frontend only ever saw mock data because .env.development.example defaulted VITE_USE_MOCKS to true -- every deployed environment already runs live (the var is never set in web-client/Dockerfile, in either docker-compose build config, or in the CD workflow's build_args, and .dockerignore excludes .env* from the build context entirely), so this was purely a local pnpm dev default, not a deployment gap. Verified end-to-end against a fresh docker compose stack: every feature (organization, members, events, payments, letters, feedback, helper, profile) renders real backend data through Traefik with zero console errors. The mock path (VITE_USE_MOCKS=true, persona switching) still works unchanged -- nothing in src/mocks/** touched. Also documents a known limitation: pnpm dev's dev-proxy strips cookies and never routes the initial page load through Traefik, so it can't satisfy the forward-auth session cookie every backend route requires -- live-mode API calls from pnpm dev itself will fail. This doesn't affect any real deployment (docker-compose/VM/Kubernetes all go through Traefik end-to-end already); for local iteration against real data, browse the docker-compose stack directly at http://localhost/ instead of pnpm dev. --- web-client/.env.development.example | 12 +++++++----- web-client/README.md | 10 ++++++++-- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/web-client/.env.development.example b/web-client/.env.development.example index 5ba4fbf..6e06e28 100644 --- a/web-client/.env.development.example +++ b/web-client/.env.development.example @@ -2,11 +2,13 @@ # automatically for `pnpm dev`. The real `.env.development` is gitignored so each # developer can choose mocks vs. live backend without committing the choice. -# Serve fixtures everywhere instead of calling the backend. -# Set to false (or delete) to run against the real services. -VITE_USE_MOCKS=true +# Defaults to the real backend (requires the full stack running -- +# `docker compose up -d --build` from infra/, not just keycloak). +# Set to true to serve fixtures everywhere instead, for UI work with no backend running. +VITE_USE_MOCKS=false -# Demo as a specific persona so member-scoped pages (dashboard feedback, payments, -# development report) resolve against that fixture identity. +# Only used when VITE_USE_MOCKS=true. Demo as a specific persona so member-scoped +# pages (dashboard feedback, payments, development report) resolve against that +# fixture identity. # Options: member | coach | director | admin VITE_MOCK_PERSONA=member diff --git a/web-client/README.md b/web-client/README.md index 15a5c7f..d154f44 100644 --- a/web-client/README.md +++ b/web-client/README.md @@ -28,13 +28,13 @@ The global theme lives in `src/index.css`. Light and dark mode are driven by sem ## Prerequisites - **Node.js 20+** and **pnpm** (`npm install -g pnpm`) -- **Keycloak running locally** on port 8081 — start it with `docker compose up -d keycloak` from `infra/` (see root README) +- **Keycloak running locally** on port 8081 — start it (and the rest of the backend, see "Mock data" below) with `docker compose up -d --build` from `infra/` (see root README) ## Local development ```bash pnpm install -pnpm dev # Vite dev server at http://localhost:5173 +pnpm dev # Vite dev server at http://localhost:3000 ``` The app requires Keycloak to be reachable at `VITE_KEYCLOAK_URL` (default `http://localhost:8081`) before it renders. On first load it redirects to the Keycloak login page. @@ -45,6 +45,12 @@ To override the Keycloak URL: VITE_KEYCLOAK_URL=http://localhost:8081 pnpm dev ``` +### Mock data + +By default `pnpm dev` calls the real backend services (proxied to `http://localhost` — see `vite.config.ts`), same as every deployed environment (docker-compose, VM, Kubernetes all already run live — none of them ever set `VITE_USE_MOCKS`). To work on the UI without the backend running, copy `.env.development.example` to `.env.development` and set `VITE_USE_MOCKS=true`; every feature then serves fixtures from `src/mocks/fixtures/` instead, scoped to a demo persona selected via `VITE_MOCK_PERSONA` (`member | coach | director | admin`). `.env.development` is gitignored, so this choice is per-developer and never committed. + +**Known limitation:** every backend route is also gated by Traefik's `forward-auth` middleware, which needs its own session cookie — established by a full-page login through Traefik, then sent automatically on same-origin requests. `pnpm dev` serves the SPA itself from Vite (port 3000), so that cookie never gets set, and live-mode API calls will fail. This doesn't affect the actual deployment: browsing the docker-compose stack directly at `http://localhost/` (rather than `pnpm dev`) goes through Traefik end-to-end and works correctly. For local UI iteration against real look-and-feel without touching this, use `VITE_USE_MOCKS=true`. + ## Authentication Authentication is handled by [`src/lib/keycloak.ts`](src/lib/keycloak.ts): From 1e42b6a355d06c09c60a9467b8686db89a99af58 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Thu, 9 Jul 2026 11:12:02 +0200 Subject: [PATCH 5/6] fix: disable autoscaling for py-genai-helper (RWO PVC conflict) Its vector-store PVC is ReadWriteOnce (see the persistence block just below), which can only mount to one node at a time -- the chart's own service catalogue doc already says persistence is "only safe with a single replica." A second HPA-spawned replica wouldn't fail cleanly, it'd sit stuck on a Multi-Attach error indefinitely. Dropping the autoscaling block entirely rather than pinning maxReplicas: 1, since a min=max=1 HPA is a no-op object with nothing to show for it -- this service now joins Postgres/Keycloak/Ollama/monitoring as intentionally not autoscaled. Found via CodeRabbit review. --- infra/helm/team-devoops/values.yaml | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml index 953a878..0395a63 100644 --- a/infra/helm/team-devoops/values.yaml +++ b/infra/helm/team-devoops/values.yaml @@ -396,11 +396,8 @@ services: # generated controllers which keep their own name in the path -- needs the # whole "/api/v1/helper" prefix removed, not just "/api/v1". fullStrip: true - autoscaling: - enabled: true - minReplicas: 1 - maxReplicas: 2 - targetCPUUtilizationPercentage: 75 + # No autoscaling: its RWO PVC below can only mount to one node at a time, so a second + # replica would never schedule (stuck on a Multi-Attach error) rather than fail cleanly. envFromSecret: genai-env env: KEYCLOAK_ISSUER_URL: "https://ge83mom-devops26.stud.k8s.aet.cit.tum.de/auth/realms/devops" From 65afb650236c3da2bb297ba9371832dc4b880bc8 Mon Sep 17 00:00:00 2001 From: Raphael Frank <04.raphael.frank@gmail.com> Date: Thu, 9 Jul 2026 11:20:51 +0200 Subject: [PATCH 6/6] fix: correct api-docs health path from / to /docs/ The manual CD run on 3df1b0c failed: api-docs' new pod's startup probe got a 404 on GET / and was killed/restarted in a loop until progressDeadlineSeconds, so helm --rollback-on-failure reverted the release. Confirmed live via kubectl describe rs (Warning Unhealthy: "HTTP probe failed with statuscode: 404") and a port-forward straight to the pod: GET / -> 404, GET /docs/ -> 200. Root cause: api/Dockerfile bakes BASE_URL=/docs into the image, so swagger-ui serves its UI under /docs, not root -- I assumed root when adding this service's health path in df9ddb4. The namespace's tight ResourceQuota was a red herring here; this pod scheduled, pulled, and started fine, it just never passed its own health check. --- infra/helm/team-devoops/values.yaml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/infra/helm/team-devoops/values.yaml b/infra/helm/team-devoops/values.yaml index 0395a63..6e415c3 100644 --- a/infra/helm/team-devoops/values.yaml +++ b/infra/helm/team-devoops/values.yaml @@ -461,7 +461,10 @@ services: path: /docs port: 8080 db: false - health: / + # Not "/" -- BASE_URL=/docs is baked into api/Dockerfile (swagger-ui serves its + # UI under that path, not root), so a bare "/" 404s. Verified live: GET / -> 404, + # GET /docs/ -> 200. + health: /docs/ stripPrefix: false open: true autoscaling: