deploy(prod): swap NetBird-Traefik labels for off-stack proxy + internal Caddy

The stack no longer talks to any specific external proxy. Removed traefik.*
labels and the shared `traefik` external network from kong + app, and dropped
TRAEFIK_NETWORK / TRAEFIK_ENTRYPOINT / TRAEFIK_CERTRESOLVER from the .env
template and the deploy-script bootstrap.

Internal edge is a `caddy:2-alpine` container on the `colectivo` network,
publishing host port 3000. `infra/Caddyfile.erosi` does the path split:
/rest|/auth|/realtime|/storage|/graphql|/pg -> kong:8000, everything else
-> app:3000. Global block sets `auto_https off` + `admin off` so :3000 is
the only listener.

Whatever proxy fronts the host (NetBird's Traefik, nginx, anything) just
terminates TLS for erosi.limonia.net and forwards plain HTTP to
ambrosio:3000. Off-stack and out of this repo.

Docs (CLAUDE.md + docs/deployment.md) updated to describe the two-layer
edge. Includes the gotcha that bind-mounted file edits (Caddyfile) need
`docker compose restart caddy` since `up -d` won't recreate the container
on file-only changes.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
2026-05-17 22:26:06 +02:00
parent dee9ee8014
commit d4a781ddcf
6 changed files with 58 additions and 59 deletions

View File

@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
## Project Status
**Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.limonia.net (app + Supabase, external Keycloak); TLS via Traefik (deployed by NetBird's self-hosted installer, Docker-provider with label-based routing); PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
**Fase 05 complete. Fase 6 complete (deploy readiness: PWA injectManifest SW + manifest + iOS meta tags + placeholder icons + Kong rate-limit on invitations + dev JWT rotation + prod env template + rls-audit.sh + Justfile lighthouse/rls-audit/test-rate-limit). Production stack deployed to ambrosio (OVH VPS) at https://erosi.limonia.net (app + Supabase, external Keycloak); edge proxy now off-stack — internal Caddy fans kong/app on host port 3000, whatever external proxy fronts the host terminates TLS and forwards plain HTTP to ambrosio:3000 (originally NetBird's self-hosted Traefik, now proxy-agnostic); PWA strategy switched to generateSW to unblock the prod build (see the "injectManifest hardcoded filename" gotcha below). Fase 7 complete: 12 new Playwright tests fill the collective-flow UI gap (O-01..O-03 onboarding, I-01..I-05 invitation acceptance, MC-02..MC-05 admin-manage) — writing them surfaced + fixed three latent bugs in the product code (atomic `create_collective()` RPC, pendingInvitationToken restore after login, manage page not reloading on late $currentCollective hydration). Plus migration 013 (auth.users role/aud default trigger) — found after the first real self-registration on prod via Keycloak→GoTrue left the user with empty `role`/`aud` columns, breaking every REST call with `role "" does not exist`. Post-fase-7 prod auth hardening (2026-04-20..21, see `docs/history/fase-8-auth-logout.md`): migration 014 extends the role/aud guard to UPDATE (GoTrue's pop-ORM resends role='' right after the INSERT and clobbered the INSERT trigger's backfill); `logout()` now does RP-initiated Keycloak end-session + lands on a new public `/logged-out` route, which both actually ends the Keycloak SSO cookie (so the next login re-prompts) and closes a PKCE-verifier race that produced "PKCE code verifier not found in storage" on prod. 259 tests green: 45 pgTAP + 140 Vitest integration + 15 Vitest unit + 59 Playwright + 1 Vitest rate-limit (gated, run via `just test-rate-limit`). 3 skipped in `just test-all` (2 Realtime presence — upstream `handle_out/3` bug; 1 rate-limit — gated). Push notifications + CI Docker + final production icons deliberately out of scope. ✅**
## Documentation Map
@@ -165,10 +165,11 @@ Defined in `keycloak/realm-export.json` and `supabase/seed.sql`. All use passwor
- Push notifications require iOS 16.4+ and the PWA must be installed to the home screen.
- Test the shopping session mode on a real iPhone during development, not only in DevTools.
**Traefik (prod) — WebSocket for Realtime:**
- Traefik routes `Host(erosi.limonia.net) && PathPrefix(/rest/v1, /auth/v1, /realtime/v1, ...)` to Kong via Docker-provider labels (priority 100); everything else on the same host falls through to the SvelteKit app (priority 1). Routing lives on the `kong` and `app` services' `labels:` block in `infra/docker-compose.erosi.yml` — no Traefik config files. NetBird's self-hosted installer (≥ v0.65) deploys Traefik with `--providers.docker=true` + `--providers.docker.exposedByDefault=false`, so our labeled services are auto-discovered on the `netbird_netbird` Docker network.
- The HTTPS entrypoint needs `respondingTimeouts.idleTimeout` set to either `0` (unlimited, NetBird's default) or `≥3600s`. At the default ~180s, Realtime WebSockets drop mid-shopping-session and the client reconnects continuously. Verify with `docker inspect netbird-traefik --format '{{range .Args}}{{.}}{{"\n"}}{{end}}' | grep idleTimeout`.
- Traefik auto-sets `X-Forwarded-{Proto,Host,Port,For}` when `tls.certresolver` is configured on the router, so no extra middleware is needed for the app.
**Edge proxy (prod):**
- Two layers. Internal: a Caddy container on the `colectivo` Docker network listens on `:3000` (host port `3000:3000`) and does the path split — `/rest/v1`, `/auth/v1`, `/realtime/v1`, `/storage/v1`, `/graphql/v1`, `/pg``kong:8000`; everything else → `app:3000`. Config in `infra/Caddyfile.erosi`, mounted into `caddy:2-alpine`. Plain HTTP, no TLS, no Host matching. Global block sets `auto_https off` + `admin off` so the only socket is `:3000`.
- External: TLS termination + outermost routing live outside this repo (originally NetBird's self-hosted Traefik on ambrosio, but the stack is proxy-agnostic now). Whatever fronts the host terminates `https://erosi.limonia.net` and forwards plain HTTP to `ambrosio:3000`. One upstream is enough — Caddy fans out internally.
- External proxy must allow long-lived idle WebSockets for Realtime (≥3600s, or unlimited). NetBird's default is `0` (unlimited). Symptom of too-short: client reconnects continuously mid-shopping-session.
- Bind-mounted files (Caddyfile, etc.) require `docker compose restart caddy` after editing. The deploy script's `up -d` only recreates services whose compose definition changed — a Caddyfile-only edit will silently keep the old config loaded.
- CSP `connect-src` must include both `https://erosi.limonia.net` and `wss://erosi.limonia.net`.
**External Keycloak:**