# 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.