Files
register-referentie/docs/architecture/adr-0034-caddy-serves-the-portals.md
T
not 1dd8bd4e1b
CI / lint (push) Successful in 1m17s
CI / build (push) Successful in 1m12s
CI / unit (push) Successful in 1m26s
CI / frontend (push) Successful in 2m58s
CI / mutation (push) Successful in 9m1s
CI / verify-stack (push) Successful in 8m53s
S-24/#25 · Helm chart + Kubernetes deployment, and Caddy for the portals (#166) (#167)
## What & why

Two changes, made and verified together on a real cluster.

**S-24 / #25 — a Helm chart for the platform.** One chart, `infra/helm/big-reference`,
whose `values.yaml` is a near-literal transcription of `infra/docker-compose.yml`, rendered
by three generic templates (Deployment, Job, Service) over a `workloads` map. Adding a
service is a values edit. `make k8s-lint` renders and schema-checks the whole stack without
a cluster. The issue asked for a *sketch*; this is deployed and verified end to end (see
below), which is more than it asked for — the part it asked for that is **not** here is the
production-posture write-up (HA, secrets, backup), see Known gaps.

**#166 — Caddy replaces nginx in the portals.** nginx resolves a variable `proxy_pass`
upstream itself, using only the `resolver` directive and never `/etc/resolv.conf`'s search
domains. That had cost two workarounds in one script: rewriting the resolver address for
rootless podman, and injecting a full FQDN so the bare `bff` name could resolve on
Kubernetes. Caddy dials per request through the system resolver, so `reverse_proxy
bff:8080` works on every engine unchanged; `apps/portal-nginx-resolver.sh` and the chart's
`BFF_HOST` env are deleted.

Closes #25
Closes #166

## Definition of Done

- [x] Linked Gitea issue (above).
- [x] Failing test committed before the implementation — twice: the Caddyfile contract test
      before the Caddyfiles, `make k8s-lint` before the chart.
- [x] Implementation makes the test pass.
- [x] Conventional Commits referencing the issues (`refs #25` / `refs #166`).
- [ ] CI green — awaiting the run on this PR (`make k8s-lint`, `dotnet format` and the new
      unit self-check pass locally; the compose e2e and mutation lanes are CI's).
- [ ] `docker compose up` from a fresh clone reaches green health checks within 3 minutes —
      the portal images were rebuilt and verified standalone, but a full `make up` run has
      not been done on this branch. Please confirm in review or let CI's smoke test speak.
- [x] Docs updated — `docs/runbooks/kubernetes-talos.md` (new), `frontend-decisions.md`,
      `demo-script.md`, and the docs that named nginx.
- [x] ADR added — ADR-0033 (chart) and ADR-0034 (Caddy).
- [ ] Demo note in `docs/demo-script.md` — not added: the deployment target is not a
      user-visible slice, and the Caddy swap is invisible to the demo script beyond the
      wording fix included here.

## How it was verified

Brought up from scratch on a single-node Talos v1.14.0 VM (6 vCPU / 10 GB, virtio disk)
under virt-manager: **29 pods ready and four bootstrap Jobs complete in under three
minutes, zero restarts**, using ~4.4 GB of the VM's 10 GB.

- Full Common Ground path: portal Caddy → BFF → domain → Flowable → ACL → OpenZaak +
  Objecten → NRC → event-subscriber → projection → public register (`INGEDIEND`, reference
  matching the submitted registration).
- Werkbak read with an MFA'd medewerker token → 200.
- The browser flow driven with Playwright against `http://localhost:30140`: secure context,
  `crypto.subtle` present, Keycloak form reached, login completed, **no console errors**.
- Routing checked against a stub BFF: SPA fallback serves deep links, each portal proxies
  its own groups, and a portal does *not* proxy a neighbour's group.

## Notes for reviewers

Three bugs this shook out, each fixed at the cause rather than the symptom:

1. **`command` vs `args`.** Compose's `command:` replaces the image CMD; Kubernetes'
   replaces the ENTRYPOINT. Transcribing one to the other broke every upstream image that
   relies on its entrypoint — postgres refused to run as root, Keycloak tried to exec
   `start-dev`. The chart now `fail`s at render time on `command`.
2. **Concurrent migrations.** Both `/setup_configuration.sh` and `/start.sh` run
   `manage.py migrate`; compose serialises them with `depends_on`, Kubernetes has no such
   edge, so the init Job and its web pod raced (`relation "zgw_consumers_service" already
   exists`). The four Django services now do both steps in order in the web pod — which
   also deletes four workloads.
3. **`emptyDir` databases are wiped by any pod-template change.** `make k8s-reseed` now
   also restarts `event-subscriber` and `projection-api`, which create the projection
   schema on start and otherwise keep writing to a schema-less database.

Known gaps / follow-ups:

- **Secrets.** `values.yaml` carries the dev credentials in plain text (`admin/admin`, the
  ZGW client secret, the two Objecten tokens) and the chart has no `Secret` objects. Fine
  for a laptop demo, and exactly what #25's "production posture" ADR should address — I
  suggest a follow-up issue rather than stretching this PR.
- **No CI gate for the chart yet.** `make k8s-lint` exists but is not wired into
  `.gitea/workflows/ci.yaml`, and nothing enforces that the chart and the compose file stay
  in step. Worth a small follow-up.
- **This is two slices in one PR.** They were built and verified together and the diff is
  entangled (the chart was written against Caddy from the start), so splitting now would
  mean re-creating an nginx-shaped chart to throw away. Happy to split if you'd rather.
- **Rebased onto #161** (merged as #165) rather than merged, to keep the history linear.
  One conflict, in the `unit:` target where both branches add a self-check line — resolved
  by keeping both. #161's `infra/host-browser.yml` arrived with
  `/usr/share/nginx/html/config.json` and is fixed to `/usr/share/caddy/` inside the
  `feat(portals)` commit, so no commit on this branch leaves that overlay pointing at a
  path the images no longer have.Reviewed-on: #167
2026-09-10 08:53:58 +00:00

5.3 KiB

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.