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.
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.
resolver 127.0.0.11(Docker's embedded DNS) is wrong on rootless podman, which uses a network-specific aardvark address — soapps/portal-nginx-resolver.shrewrote the directive at container start by reading the pod's actual nameserver.- On Kubernetes the bare
bffname cannot resolve at all without thesvc.cluster.localsearch domain, so the same script gained aBFF_HOSToverride 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 gzipand a cache policy for immutable Angular bundles; a real hostname makes TLS a one-lineCaddyfilechange, 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
curlfor free (the alpine nginx image had only busyboxwget), which the compose healthchecks can use. - Routing intent is readable: one
handleblock 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-alpinecarries a bigger static binary than nginx's. Measured, not estimated. - Caddy's directive-order rule is a genuine footgun (see above); the
handleform 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.