feat(infra): local serve-static profile — Caddy reverse proxy for the prod build (#97)
## Summary Add a Caddy reverse proxy behind a new `--profile serve-static` so a contributor can exercise the production build locally with the per-locale routing the on-prem reverse proxy will use (per ADR-0019). Closes the gap surfaced by PR #96: the locale switcher / accessibility 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, exposes the `http-server` directory-listing footgun we hit during the perf-gate fix in PR #92). ## What lands - **[`infra/local/Caddyfile`](infra/local/Caddyfile)** — explicit `route` block: - `GET /` → 302 to `/{locale}/` based on `Accept-Language`, falling back to `/fr/` (APF audience). - `/fr/*` → `dist/.../browser/fr/` with SPA fallback to `fr/index.html`. - `/en/*` → mirror. - Catch-all → 302 to `/fr/`. - **[`infra/local/dev.compose.yml`](infra/local/dev.compose.yml)** — new `serve-static` service on the `serve-static` profile. Bind-mounts the Caddyfile and `dist/apps/portal-shell/browser/` read-only. Port 4200, overridable via `SERVE_STATIC_PORT`. - **[`infra/local/.env.example`](infra/local/.env.example)** — adds `SERVE_STATIC_PORT=4200`. - **[`infra/local/dev.sh`](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`](infra/README.md)** — file row, workflow snippet, cheat-sheet row, and a service-endpoint row with the `nx build … -c=production` prerequisite called out. ## Workflow ``` 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 ``` ## Decision worth flagging Used **Caddy** rather than nginx or Traefik. Reason: minimal Caddyfile, single binary, no daemon config drift, sensible defaults (TLS off explicitly for local-only). Same family of choice as the rest of `infra/local/` — small, single-purpose images. `redir` in a Caddyfile is **ambiguous** when the first arg starts with `/`: Caddy reads it as a path matcher rather than a redirect target. Using `redir * /fr/ 302` (explicit `*` matcher) avoids the gotcha. Documented inline in the Caddyfile via a comment block. ## What this PR explicitly does NOT do - Wire TLS. Local convenience only, binds to `localhost`. - Replace `nx run portal-shell:serve-static` (still used by Lighthouse CI in `ci:perf`). - Set the `__Host-portal_locale` cookie or honour it for the smart redirect. Cookie handling needs the BFF route (ADR-0019 future PR). - Land an on-prem reverse-proxy ADR. The on-prem infra ADR is phase 3b. ## Verified locally | Probe | Expected | Actual | |---|---|---| | `GET / -H 'Accept-Language: fr'` | 302 `/fr/` | ✓ | | `GET / -H '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 (asset under locale folder) | ✓ | | `/fr/index.html` markup | `lang="fr"`, `<base href="/fr/">` | ✓ | | `/en/index.html` markup | `lang="en"`, `<base href="/en/">` | ✓ | ## Test plan - [x] `docker compose -f infra/local/dev.compose.yml --profile serve-static config` validates clean. - [x] `dev.sh up serve-static` brings the container up; `dev.sh status` lists it; `dev.sh stop serve-static` brings it down. - [x] Routing probes above all pass. - [ ] Manual: build + serve-static + click the locale switcher → URL becomes `/{other-locale}/`, the matching bundle boots, no console errors. (Verifies PR #95 + #96 end-to-end against a prod-faithful proxy.) - [ ] Manual: `/fr/accessibilite` → router-level redirect to `/fr/accessibility` (verifies PR #94 under SPA fallback). - [ ] Manual: `Accept-Language: en` in browser settings → root URL lands on `/en/`. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #97
This commit was merged in pull request #97.
This commit is contained in:
+9
-5
@@ -140,15 +140,16 @@ Old, no-longer-referenced images can be reaped during the periodic `docker syste
|
|||||||
|
|
||||||
## Local-dev stack — `local/`
|
## 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 |
|
| File | Role |
|
||||||
| -------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
|
| -------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------- |
|
||||||
| [`local/dev.sh`](local/dev.sh) | Convenience wrapper around `docker compose` — see "Convenience script" below |
|
| [`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/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/.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/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/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
|
### First-time setup
|
||||||
|
|
||||||
@@ -163,9 +164,10 @@ $EDITOR infra/local/.env
|
|||||||
# 2. Bring up the core stack (postgres + redis + otel-collector).
|
# 2. Bring up the core stack (postgres + redis + otel-collector).
|
||||||
./infra/local/dev.sh up
|
./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 dbtools # adds pgweb
|
||||||
./infra/local/dev.sh up observability # adds Jaeger UI
|
./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
|
./infra/local/dev.sh up all # core + every profile
|
||||||
|
|
||||||
# 4. Verify health.
|
# 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 all` | Core + every profile |
|
||||||
| `./infra/local/dev.sh up dbtools` | Core + pgweb |
|
| `./infra/local/dev.sh up dbtools` | Core + pgweb |
|
||||||
| `./infra/local/dev.sh up observability` | Core + Jaeger |
|
| `./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` | 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 down -v` | Tear down + wipe named volumes (incl. audit-roles bootstrap) |
|
||||||
| `./infra/local/dev.sh stop pgweb` | Stop one service (containers stay around) |
|
| `./infra/local/dev.sh stop pgweb` | Stop one service (containers stay around) |
|
||||||
@@ -201,13 +204,14 @@ If you prefer to call `docker compose` directly, every example below shows the r
|
|||||||
### Service endpoints (defaults)
|
### Service endpoints (defaults)
|
||||||
|
|
||||||
| Service | Host port | Purpose |
|
| Service | Host port | Purpose |
|
||||||
| ------------------- | --------- | ------------------------------------------------------------------- |
|
| ---------------------------- | --------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| Postgres | 5432 | DB connection — `postgres://portal:<pwd>@localhost:5432/portal_dev` |
|
| Postgres | 5432 | DB connection — `postgres://portal:<pwd>@localhost:5432/portal_dev` |
|
||||||
| Redis | 6379 | Sessions, OBO cache (per ADR-0010 / ADR-0014) |
|
| 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 gRPC | 4317 | `OTEL_EXPORTER_OTLP_ENDPOINT` for the BFF and the SPA |
|
||||||
| OTel Collector HTTP | 4318 | OTLP/HTTP variant |
|
| OTel Collector HTTP | 4318 | OTLP/HTTP variant |
|
||||||
| pgweb (profile) | 8081 | http://localhost:8081 — Postgres GUI |
|
| pgweb (profile) | 8081 | http://localhost:8081 — Postgres GUI |
|
||||||
| Jaeger UI (profile) | 16686 | http://localhost:16686 — trace explorer |
|
| 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.
|
All ports are overridable via `.env` if the host machine has conflicts.
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ REDIS_PORT=6379
|
|||||||
OTEL_GRPC_PORT=4317
|
OTEL_GRPC_PORT=4317
|
||||||
OTEL_HTTP_PORT=4318
|
OTEL_HTTP_PORT=4318
|
||||||
|
|
||||||
# ---------------------------------------------------------------- Optional viewers
|
# ---------------------------------------------------------------- Optional viewers / tooling
|
||||||
# Only consumed when the matching Compose profile is activated:
|
# Only consumed when the matching Compose profile is activated:
|
||||||
# --profile dbtools → pgweb
|
# --profile dbtools → pgweb
|
||||||
# --profile observability → Jaeger UI
|
# --profile observability → Jaeger UI
|
||||||
|
# --profile serve-static → Caddy serving the production build
|
||||||
PGWEB_PORT=8081
|
PGWEB_PORT=8081
|
||||||
JAEGER_UI_PORT=16686
|
JAEGER_UI_PORT=16686
|
||||||
|
SERVE_STATIC_PORT=4200
|
||||||
|
|||||||
@@ -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/<any> → serve `dist/.../browser/fr/<any>`, with SPA
|
||||||
|
# fallback to `fr/index.html` for deep links.
|
||||||
|
# - GET /en/<any> → 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 `<path>` 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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,9 +8,13 @@
|
|||||||
# BFF and the SPA, debug-logs traces in
|
# BFF and the SPA, debug-logs traces in
|
||||||
# v1, forwards to Jaeger when activated)
|
# v1, forwards to Jaeger when activated)
|
||||||
#
|
#
|
||||||
# Optional viewers behind Compose profiles:
|
# Optional viewers / tooling behind Compose profiles:
|
||||||
# - --profile dbtools → pgweb (Postgres GUI)
|
# - --profile dbtools → pgweb (Postgres GUI)
|
||||||
# - --profile observability → Jaeger UI (trace explorer)
|
# - --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/:
|
# Usage from infra/local/:
|
||||||
# cp .env.example .env
|
# cp .env.example .env
|
||||||
@@ -151,6 +155,40 @@ services:
|
|||||||
networks:
|
networks:
|
||||||
- apf-portal-dev
|
- 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:
|
volumes:
|
||||||
postgres-data:
|
postgres-data:
|
||||||
name: apf-portal-postgres-data
|
name: apf-portal-postgres-data
|
||||||
|
|||||||
+2
-1
@@ -16,7 +16,7 @@ COMPOSE_FILE="${SCRIPT_DIR}/dev.compose.yml"
|
|||||||
|
|
||||||
# Profiles defined in dev.compose.yml. Keep in sync if a new profile
|
# Profiles defined in dev.compose.yml. Keep in sync if a new profile
|
||||||
# is added.
|
# is added.
|
||||||
ALL_PROFILES=(dbtools observability)
|
ALL_PROFILES=(dbtools observability serve-static)
|
||||||
|
|
||||||
# Build "--profile p1 --profile p2 …" as separate arguments.
|
# Build "--profile p1 --profile p2 …" as separate arguments.
|
||||||
build_all_profile_flags() {
|
build_all_profile_flags() {
|
||||||
@@ -59,6 +59,7 @@ Commands:
|
|||||||
all core + all profiles
|
all core + all profiles
|
||||||
dbtools core + pgweb
|
dbtools core + pgweb
|
||||||
observability core + jaeger
|
observability core + jaeger
|
||||||
|
serve-static core + caddy (production-build reverse proxy)
|
||||||
Multiple targets allowed (e.g. `up dbtools observability`).
|
Multiple targets allowed (e.g. `up dbtools observability`).
|
||||||
|
|
||||||
down [-v] Tear the stack down. Always runs with every
|
down [-v] Tear the stack down. Always runs with every
|
||||||
|
|||||||
Reference in New Issue
Block a user