feat(infra): local serve-static profile — Caddy reverse proxy for the prod build
CI / commits (pull_request) Successful in 2m33s
CI / scan (pull_request) Successful in 2m33s
CI / check (pull_request) Successful in 2m41s
CI / a11y (pull_request) Successful in 2m18s
CI / perf (pull_request) Successful in 5m50s

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 `<base href="/fr/">`
- /en/index.html has `lang="en"` and `<base href="/en/">`

This is local convenience only — no TLS, no auth, binds to
localhost. The on-prem reverse proxy gets its own ADR (phase 3b).
This commit is contained in:
Julien Gautier
2026-05-12 00:03:59 +02:00
parent d118d09aba
commit 1a8e2cbb21
5 changed files with 139 additions and 20 deletions
+39 -1
View File
@@ -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}/<deep-link>` 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.
# <repo>/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