From 1a8e2cbb21e71b077f17a1b86c530ef2352294b7 Mon Sep 17 00:00:00 2001 From: Julien Gautier Date: Tue, 12 May 2026 00:03:59 +0200 Subject: [PATCH] =?UTF-8?q?feat(infra):=20local=20serve-static=20profile?= =?UTF-8?q?=20=E2=80=94=20Caddy=20reverse=20proxy=20for=20the=20prod=20bui?= =?UTF-8?q?ld?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a Caddy reverse proxy behind a new `--profile serve-static` so a contributor can exercise the production build with the per-locale routing that the on-prem reverse proxy will use (ADR-0019). Closes the gap PR #96 surfaced: the locale switcher / route fusion / cookie plumbing all need a prod-faithful local setup, and `nx serve-static` falls short (no SPA fallback per locale, no smart `/` redirect). What lands: - `infra/local/Caddyfile` — three-block route: * `GET /` → smart redirect to `/{locale}/` based on `Accept-Language` (FR fallback, since the APF audience is francophone). Cookie support waits on the BFF route in ADR-0019. * `/fr/*` → serves `dist/.../browser/fr/` with SPA fallback to `index.html` for deep links. * `/en/*` → mirror. * Catch-all → bounces to `/fr/` so a typo can't land on the Caddy directory-listing footgun (same family as the perf-gate fix in #92). - `infra/local/dev.compose.yml` — new `serve-static` service on the `serve-static` profile, bind-mounting the Caddyfile and the `dist/apps/portal-shell/browser/` folder (read-only). Default port 4200, override via `SERVE_STATIC_PORT` in `.env`. - `infra/local/.env.example` — adds `SERVE_STATIC_PORT=4200`. - `infra/local/dev.sh` — registers `serve-static` in `ALL_PROFILES` so `dev.sh down|status|logs` catches the new container, and `dev.sh up serve-static` works. - `infra/README.md` — adds the new file row, the workflow in the "First-time setup" snippet, the cheat-sheet row, and the service endpoint row with the prerequisite `nx build … -c=production`. Workflow once merged: ``` pnpm exec nx build portal-shell --configuration=production ./infra/local/dev.sh up serve-static open http://localhost:4200/ # → /fr/ or /en/ per Accept-Language ``` Verified locally: - GET / with Accept-Language=fr → 302 /fr/ - GET / with Accept-Language=en → 302 /en/ - GET /unknown → 302 /fr/ - GET /fr/deep/route → 200 (SPA fallback to fr/index.html) - GET /fr/favicons/favicon.svg → 200 (per-locale asset served) - /fr/index.html has `lang="fr"` and `` - /en/index.html has `lang="en"` and `` This is local convenience only — no TLS, no auth, binds to localhost. The on-prem reverse proxy gets its own ADR (phase 3b). --- infra/README.md | 38 ++++++++++--------- infra/local/.env.example | 4 +- infra/local/Caddyfile | 74 +++++++++++++++++++++++++++++++++++++ infra/local/dev.compose.yml | 40 +++++++++++++++++++- infra/local/dev.sh | 3 +- 5 files changed, 139 insertions(+), 20 deletions(-) create mode 100644 infra/local/Caddyfile diff --git a/infra/README.md b/infra/README.md index 03e82fa..09dfabc 100644 --- a/infra/README.md +++ b/infra/README.md @@ -140,15 +140,16 @@ Old, no-longer-referenced images can be reaped during the periodic `docker syste ## Local-dev stack — `local/` -A Docker Compose recipe spinning up the runtime services the BFF and ADRs assume — Postgres, Redis, OpenTelemetry Collector — plus optional viewers (pgweb, Jaeger UI) gated behind Compose profiles. Designed to start in a single command on a contributor's WSL2 / Linux / macOS host. +A Docker Compose recipe spinning up the runtime services the BFF and ADRs assume — Postgres, Redis, OpenTelemetry Collector — plus optional viewers / tooling (pgweb, Jaeger UI, Caddy serve-static) gated behind Compose profiles. Designed to start in a single command on a contributor's WSL2 / Linux / macOS host. -| File | Role | -| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | -| [`local/dev.sh`](local/dev.sh) | Convenience wrapper around `docker compose` — see "Convenience script" below | -| [`local/dev.compose.yml`](local/dev.compose.yml) | Service definitions: postgres, redis, otel-collector, plus pgweb and jaeger behind profiles | -| [`local/.env.example`](local/.env.example) | Credentials + ports template (copy to `.env`, which is git-ignored) | -| [`local/init/postgres/01-init.sql`](local/init/postgres/01-init.sql) | Bootstrap SQL for ADR-0013: audit roles + schema, applied on first boot only | -| [`local/otel-collector.yaml`](local/otel-collector.yaml) | Collector pipeline: OTLP receivers → batch → debug exporter (always) + forward to Jaeger when active | +| File | Role | +| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| [`local/dev.sh`](local/dev.sh) | Convenience wrapper around `docker compose` — see "Convenience script" below | +| [`local/dev.compose.yml`](local/dev.compose.yml) | Service definitions: postgres, redis, otel-collector, plus pgweb / jaeger / caddy behind profiles | +| [`local/.env.example`](local/.env.example) | Credentials + ports template (copy to `.env`, which is git-ignored) | +| [`local/init/postgres/01-init.sql`](local/init/postgres/01-init.sql) | Bootstrap SQL for ADR-0013: audit roles + schema, applied on first boot only | +| [`local/otel-collector.yaml`](local/otel-collector.yaml) | Collector pipeline: OTLP receivers → batch → debug exporter (always) + forward to Jaeger when active | +| [`local/Caddyfile`](local/Caddyfile) | Reverse-proxy config for the `serve-static` profile — per-locale SPA fallback + smart `/` redirect (ADR-0019) | ### First-time setup @@ -163,9 +164,10 @@ $EDITOR infra/local/.env # 2. Bring up the core stack (postgres + redis + otel-collector). ./infra/local/dev.sh up -# 3. (Optional) Activate viewers when needed: +# 3. (Optional) Activate viewers / tooling when needed: ./infra/local/dev.sh up dbtools # adds pgweb ./infra/local/dev.sh up observability # adds Jaeger UI +./infra/local/dev.sh up serve-static # adds caddy serving the prod build ./infra/local/dev.sh up all # core + every profile # 4. Verify health. @@ -187,6 +189,7 @@ Run `./infra/local/dev.sh help` for the full reference. Cheat-sheet: | `./infra/local/dev.sh up all` | Core + every profile | | `./infra/local/dev.sh up dbtools` | Core + pgweb | | `./infra/local/dev.sh up observability` | Core + Jaeger | +| `./infra/local/dev.sh up serve-static` | Core + Caddy serving `dist/.../browser/` per ADR-0019 | | `./infra/local/dev.sh down` | Tear down the whole stack (every profile in scope) | | `./infra/local/dev.sh down -v` | Tear down + wipe named volumes (incl. audit-roles bootstrap) | | `./infra/local/dev.sh stop pgweb` | Stop one service (containers stay around) | @@ -200,14 +203,15 @@ If you prefer to call `docker compose` directly, every example below shows the r ### Service endpoints (defaults) -| Service | Host port | Purpose | -| ------------------- | --------- | ------------------------------------------------------------------- | -| Postgres | 5432 | DB connection — `postgres://portal:@localhost:5432/portal_dev` | -| Redis | 6379 | Sessions, OBO cache (per ADR-0010 / ADR-0014) | -| OTel Collector gRPC | 4317 | `OTEL_EXPORTER_OTLP_ENDPOINT` for the BFF and the SPA | -| OTel Collector HTTP | 4318 | OTLP/HTTP variant | -| pgweb (profile) | 8081 | http://localhost:8081 — Postgres GUI | -| Jaeger UI (profile) | 16686 | http://localhost:16686 — trace explorer | +| Service | Host port | Purpose | +| ---------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Postgres | 5432 | DB connection — `postgres://portal:@localhost:5432/portal_dev` | +| Redis | 6379 | Sessions, OBO cache (per ADR-0010 / ADR-0014) | +| OTel Collector gRPC | 4317 | `OTEL_EXPORTER_OTLP_ENDPOINT` for the BFF and the SPA | +| OTel Collector HTTP | 4318 | OTLP/HTTP variant | +| pgweb (profile) | 8081 | http://localhost:8081 — Postgres GUI | +| Jaeger UI (profile) | 16686 | http://localhost:16686 — trace explorer | +| Caddy serve-static (profile) | 4200 | http://localhost:4200/ — production build with per-locale routing (`/fr/`, `/en/`) + smart `/` redirect, per ADR-0019. Run `pnpm exec nx build portal-shell --configuration=production` first or the proxy will 404 everything. | All ports are overridable via `.env` if the host machine has conflicts. diff --git a/infra/local/.env.example b/infra/local/.env.example index bf7e67f..c3965e6 100644 --- a/infra/local/.env.example +++ b/infra/local/.env.example @@ -27,9 +27,11 @@ REDIS_PORT=6379 OTEL_GRPC_PORT=4317 OTEL_HTTP_PORT=4318 -# ---------------------------------------------------------------- Optional viewers +# ---------------------------------------------------------------- Optional viewers / tooling # Only consumed when the matching Compose profile is activated: # --profile dbtools → pgweb # --profile observability → Jaeger UI +# --profile serve-static → Caddy serving the production build PGWEB_PORT=8081 JAEGER_UI_PORT=16686 +SERVE_STATIC_PORT=4200 diff --git a/infra/local/Caddyfile b/infra/local/Caddyfile new file mode 100644 index 0000000..cdc2680 --- /dev/null +++ b/infra/local/Caddyfile @@ -0,0 +1,74 @@ +# Local manual-test reverse proxy for `portal-shell`'s production +# build, simulating what the on-prem reverse proxy will do (per +# ADR-0019 §"URL strategy"): +# +# - GET / → smart redirect to /{locale}/ based on +# Accept-Language (cookie support lands when +# the BFF route is in place). Falls back to +# `/fr/` since the APF audience is francophone. +# - GET /fr/ → serve `dist/.../browser/fr/`, with SPA +# fallback to `fr/index.html` for deep links. +# - GET /en/ → mirror for the EN bundle. +# - everything else → redirect to `/fr/` (avoids the http-server +# directory-listing footgun we hit in the perf +# gate while the previous fix landed). +# +# Used by: +# - `./infra/local/dev.sh up serve-static` (after building the +# production bundle with `nx build portal-shell -c=production`). +# - Convenient for testing the locale switcher end-to-end, the +# accessibility-route redirect, dark-mode preference plumbing, +# etc. — anything that needs both locale folders served at their +# own prefix. + +{ + auto_https off + admin off +} + +:4200 { + encode gzip zstd + + log { + output stdout + format console + level INFO + } + + # Group the locale-routing logic in an explicit `route` block so + # directive ordering follows source order, not Caddy's default + # directive priority — handle / handle_path / redir would + # otherwise interleave in surprising ways. + route { + # Smart redirect for the bare root. + @root path / + handle @root { + @prefers_en header Accept-Language en* + redir @prefers_en /en/ 302 + redir * /fr/ 302 + } + + # French bundle. `handle_path` strips `/fr` before passing the + # request through, so `try_files` looks at `` relative + # to the bundle root (`/srv/dist/fr/`). Missing files fall + # back to `index.html`, exactly like a production deep-link + # (e.g. /fr/accessibility) would do via a real proxy. + handle_path /fr/* { + root * /srv/dist/fr + try_files {path} /index.html + file_server + } + + # English bundle, mirror of the above. + handle_path /en/* { + root * /srv/dist/en + try_files {path} /index.html + file_server + } + + # Anything that didn't match a locale prefix falls back to FR + # home — same defaulting as the bare root above. Keeps + # typo'd URLs from landing on a 404. + redir * /fr/ 302 + } +} diff --git a/infra/local/dev.compose.yml b/infra/local/dev.compose.yml index 99f2cf4..5e6af64 100644 --- a/infra/local/dev.compose.yml +++ b/infra/local/dev.compose.yml @@ -8,9 +8,13 @@ # BFF and the SPA, debug-logs traces in # v1, forwards to Jaeger when activated) # -# Optional viewers behind Compose profiles: +# Optional viewers / tooling behind Compose profiles: # - --profile dbtools → pgweb (Postgres GUI) # - --profile observability → Jaeger UI (trace explorer) +# - --profile serve-static → Caddy reverse proxy that serves the +# production build with the per-locale +# routing the production reverse proxy +# will use (per ADR-0019) # # Usage from infra/local/: # cp .env.example .env @@ -151,6 +155,40 @@ services: networks: - apf-portal-dev + # ------------------------------------------------------------------ + # Optional: Caddy reverse proxy for the production build + # (`--profile serve-static`). + # + # Workflow: + # pnpm exec nx build portal-shell --configuration=production + # ./infra/local/dev.sh up serve-static + # open http://localhost:${SERVE_STATIC_PORT:-4200}/ + # + # Mirrors what the production reverse proxy will do per ADR-0019: + # smart redirect at `/` (Accept-Language → `/fr/` or `/en/`), + # per-locale SPA fallback, and `/{locale}/` routing. + # No locking-down on TLS / auth — this is a local convenience that + # boots in <1 s and binds to localhost only. + # ------------------------------------------------------------------ + serve-static: + image: caddy:2.10-alpine + container_name: apf-portal-serve-static + restart: unless-stopped + profiles: [serve-static] + command: ['caddy', 'run', '--config', '/etc/caddy/Caddyfile', '--adapter', 'caddyfile'] + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + # Bind-mount the production build folder read-only. The path + # resolves relative to this compose file, i.e. + # /dist/apps/portal-shell/browser/. Empty until the + # production build runs — caddy will 404 every request in that + # state, which is the right signal. + - ../../dist/apps/portal-shell/browser:/srv/dist:ro + ports: + - '${SERVE_STATIC_PORT:-4200}:4200' + networks: + - apf-portal-dev + volumes: postgres-data: name: apf-portal-postgres-data diff --git a/infra/local/dev.sh b/infra/local/dev.sh index d66bedb..ffb81e4 100755 --- a/infra/local/dev.sh +++ b/infra/local/dev.sh @@ -16,7 +16,7 @@ COMPOSE_FILE="${SCRIPT_DIR}/dev.compose.yml" # Profiles defined in dev.compose.yml. Keep in sync if a new profile # is added. -ALL_PROFILES=(dbtools observability) +ALL_PROFILES=(dbtools observability serve-static) # Build "--profile p1 --profile p2 …" as separate arguments. build_all_profile_flags() { @@ -59,6 +59,7 @@ Commands: all core + all profiles dbtools core + pgweb observability core + jaeger + serve-static core + caddy (production-build reverse proxy) Multiple targets allowed (e.g. `up dbtools observability`). down [-v] Tear the stack down. Always runs with every -- 2.30.2