diff --git a/docs/architecture/adr-0032-werkbak-live-refresh.md b/docs/architecture/adr-0032-werkbak-live-refresh.md index 090c37d..41ca85c 100644 --- a/docs/architecture/adr-0032-werkbak-live-refresh.md +++ b/docs/architecture/adr-0032-werkbak-live-refresh.md @@ -18,7 +18,7 @@ Three forces shape the mechanism: - **Nothing notifies anyone.** The trigger lives in Flowable. The domain does not publish task events, and there is no bus between the domain and the BFF. -- **The BFF is stateless** and sits behind each portal's nginx. +- **The BFF is stateless** and sits behind each portal's reverse proxy. - **This is the repo's first live-updating view**, so the choice sets a precedent. ## Decision @@ -40,7 +40,7 @@ werkbak is readable at all. Neither buys freshness here, because **nothing notifies the BFF either**: - **SSE** (`text/event-stream`) would mean a new streaming endpoint whose handler polls the - domain and forwards diffs — the same latency, plus connection lifecycle, nginx + domain and forwards diffs — the same latency, plus connection lifecycle, proxy buffering, and auth on a long-lived connection. - **WebSocket/SignalR** adds a dependency (CLAUDE.md §13) and makes the BFF stateful and sticky-session-bound. A genuine push path would *also* need the domain to publish task diff --git a/docs/architecture/adr-0034-caddy-serves-the-portals.md b/docs/architecture/adr-0034-caddy-serves-the-portals.md new file mode 100644 index 0000000..7866fe9 --- /dev/null +++ b/docs/architecture/adr-0034-caddy-serves-the-portals.md @@ -0,0 +1,103 @@ +# ADR-0034: The portals are served by Caddy, not nginx + +- **Status:** Accepted +- **Date:** 2026-09-04 +- **Deciders:** Respellion engineering +- **Slice:** _(none yet — raised directly alongside the Kubernetes deployment, ADR-0033)_ + +## Context + +Each portal ships as one image that does two jobs: serve the built Angular app, and +reverse-proxy *its own* BFF endpoint group so the browser calls a single origin (no CORS, +and the DigiD/medewerker token rides along — ADR-0010, ADR-0013). Until now that was nginx +with a hand-written `nginx.conf` per app. + +Two workarounds had accumulated around nginx's resolver, both for the same root cause: +**nginx resolves a variable `proxy_pass` upstream itself**, using only the `resolver` +directive, and never the search domains in `/etc/resolv.conf`. + +1. `resolver 127.0.0.11` (Docker's embedded DNS) is wrong on rootless podman, which uses a + network-specific aardvark address — so `apps/portal-nginx-resolver.sh` rewrote the + directive at container start by reading the pod's actual nameserver. +2. On Kubernetes the bare `bff` name cannot resolve at all without the `svc.cluster.local` + search domain, so the same script gained a `BFF_HOST` override that the Helm chart set + per portal (ADR-0033). + +Both existed only to tell the proxy how to resolve one hostname. + +## Decision + +**Serve the portals with `caddy:2-alpine` and a small `Caddyfile` per app, replacing the +nginx runtime stage, the four `nginx.conf` files, and the resolver workaround.** + +Caddy dials its upstream per request through Go's resolver, which reads +`/etc/resolv.conf` — nameserver *and* search domains. So `reverse_proxy bff:8080` resolves +correctly under Docker, rootless podman and Kubernetes with no per-engine configuration, +and it still starts before the BFF exists and picks up its restarts (the property the +variable `proxy_pass` was there to buy). `apps/portal-nginx-resolver.sh`, its unit test and +the chart's `BFF_HOST` env are deleted. + +The Caddyfile uses `handle` blocks rather than a bare `try_files`: + +``` +handle /behandel/* { reverse_proxy bff:8080 } +handle { root * /usr/share/caddy; try_files {path} /index.html; file_server } +``` + +`handle` blocks are mutually exclusive and matched most-specific-first. This matters: +Caddy's default directive order puts rewrites (`try_files`) *before* `reverse_proxy`, so a +top-level `try_files {path} /index.html` would rewrite every API path to `/index.html` +before the proxy ever saw it — the SPA fallback would silently eat the API. The `handle` +form makes the routing explicit instead of relying on directive-order trivia. + +`infra/test_portal_caddyfiles.py` (in `make unit`) asserts each portal proxies exactly its +own endpoint groups and keeps the SPA fallback. The four files are near-identical, so a +copy-paste slip is cheap to make and expensive to find: proxying another portal's group +hands a browser an endpoint its token isn't for, and the failure surfaces as a 401 three +services away. + +### Alternatives considered + +- **Keep nginx.** Zero migration, and it works — but the resolver workaround stays, and it + had already grown a second head for Kubernetes. Both heads are nginx-specific. +- **Keep nginx, hard-code the FQDN.** Would need a different config per deployment target + (compose vs Kubernetes), which is exactly the fork the chart was written to avoid. +- **Drop the proxy and use CORS.** Turns the same-origin design (ADR-0010) inside out: + CORS preflights, an explicit origin allowlist in the BFF, and a token attached + cross-origin. Not a serving decision — an architectural regression. +- **Kubernetes Ingress in front of the portals.** Solves nothing about compose, adds a + controller, and the portals would still need something to serve static files. + +- ponytail ceiling: plain HTTP on `:80`, no compression, no cache headers beyond Caddy's + defaults, and Caddy's automatic HTTPS deliberately unused (there is no hostname to get a + certificate for). Upgrade path: `encode zstd gzip` and a cache policy for immutable + Angular bundles; a real hostname makes TLS a one-line `Caddyfile` change, which is the + main reason this is worth having in place. + +## Consequences + +**Positive** + +- One resolver behaviour across compose, podman and Kubernetes; a script, a unit test and a + chart env var are deleted rather than maintained. +- The images gain `curl` for free (the alpine nginx image had only busybox `wget`), which + the compose healthchecks can use. +- Routing intent is readable: one `handle` block per endpoint group, one for the app. +- TLS later is a one-line change instead of a new component. + +**Negative / costs** + +- A new runtime dependency in four images (CLAUDE.md §13): Caddy replaces nginx rather than + joining it, so the count is unchanged, but it is a less familiar config language for + anyone who has only read nginx configs. +- The images grew: 90.6 MB against nginx's 75.7 MB, because `caddy:2-alpine` carries a + bigger static binary than nginx's. Measured, not estimated. +- Caddy's directive-order rule is a genuine footgun (see above); the `handle` form and the + Caddyfile comments exist to keep the next person out of it. +- Any operational note that says "the portal's nginx" is now wrong; the ones in `docs/` were + updated with this ADR. + +## Coupling rules touched (CLAUDE.md §8) + +None. §8.3 is unchanged and unchanged in kind: the portals still talk only to the BFF, and +the proxy is still the thing that makes that same-origin. diff --git a/docs/demo-script.md b/docs/demo-script.md index 7500cc2..ee0adf8 100644 --- a/docs/demo-script.md +++ b/docs/demo-script.md @@ -389,7 +389,7 @@ make verify-e2e # → login as jan-burger → submit → "ontvangen" co open http://localhost:8140 ``` -> The portal is served same-origin with the BFF (nginx proxies `/self-service` + `/openbaar`), so no +> The portal is served same-origin with the BFF (Caddy proxies `/self-service` + `/openbaar`), so no > CORS; the OIDC authority comes from `/config.json` at runtime. See `docs/frontend-decisions.md`. --- diff --git a/docs/frontend-decisions.md b/docs/frontend-decisions.md index 4ceb8e5..25278da 100644 --- a/docs/frontend-decisions.md +++ b/docs/frontend-decisions.md @@ -77,11 +77,13 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her ## Serving + e2e (S-08d, #68) -- **Served by nginx, same-origin as the BFF.** The compose `self-service` image serves the built app +- **Served by Caddy, same-origin as the BFF.** The compose `self-service` image serves the built app and **reverse-proxies** `/self-service/*` + `/openbaar/*` to the `bff` service. Because the - api-client uses **relative URLs**, the browser calls the app's own origin → nginx forwards to the - BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. nginx resolves - the BFF at request time (a `resolver` + variable `proxy_pass`) so it starts before the BFF is up. + api-client uses **relative URLs**, the browser calls the app's own origin → Caddy forwards to the + BFF: **no CORS**, and the DigiD token (same-origin) is attached by the interceptor. Caddy dials + the BFF per request through the system resolver, so it starts before the BFF is up, picks up its + restarts, and resolves the bare `bff` name on every engine — compose, podman and Kubernetes + (ADR-0034; the `Caddyfile` sits next to each app's `Dockerfile`). - **Runtime config.** The app fetches `/config.json` before bootstrap (`main.ts`); `appConfig` is a factory. The dev default (`public/config.json`) points at `localhost:8180`; the Docker image bakes the compose value (`keycloak:8080`). One build, per-environment OIDC authority. @@ -110,7 +112,7 @@ with the submit form (S-08c, #67); any deviation from NL DS will be recorded her `angular-auth-oidc-client`, no interceptor, and no `config.json` — `main.ts` bootstraps `appConfig` directly with just `provideHttpClient` + `provideRouter`. This is the deliberate contrast to self-service and keeps the app trivially cacheable/CDN-able. -- **Same-origin via nginx, like self-service.** The compose `openbaar` image serves the built app and +- **Same-origin via Caddy, like self-service.** The compose `openbaar` image serves the built app and reverse-proxies `/openbaar` to the BFF; the api-client's relative calls stay same-origin (no CORS). Served on `:8141`, health-checked over IPv4 (`127.0.0.1`), no Keycloak dependency. - **Public-safe by construction.** The portal only ever sees the BFF's `OpenbaarProjection.PublicView` @@ -138,7 +140,7 @@ frontend work is the medewerker realm auth and the werkbak/decide page. Wiring r **BFF remains the security boundary** (`behandelaar` policy, 401/403 on `/behandel/*`, ADR-0013); the frontend role signal is for display/UX, and the werkbak page surfaces a load failure (e.g. a 403 for a non-behandelaar) rather than swallowing it. -- **Same-origin via nginx, like the other portals.** The compose `behandel` image serves the built +- **Same-origin via Caddy, like the other portals.** The compose `behandel` image serves the built app and reverse-proxies `/behandel` to the BFF (relative calls, no CORS). Served on `:8142`, health-checked over IPv4 (`127.0.0.1`), depends on Keycloak for the medewerker realm. - **Werkbak = decide-and-refresh.** `WerkbakPage` loads `GET /behandel/werkbak` on open and renders a