Files
register-referentie/docs/architecture/adr-0034-caddy-serves-the-portals.md
T
not 0fe4813388 docs(portals): ADR-0034 — Caddy serves the portals (refs #166)
Records the decision, the directive-order footgun that shapes the Caddyfiles, and
the measured cost (the images grew 75.7 MB → 90.6 MB). Also updates the three
frontend-decisions entries and the two other docs that named nginx.
2026-09-04 17:25:29 +02: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.