feat(portals): serve each portal with Caddy instead of nginx (refs #166)

nginx resolves a variable `proxy_pass` upstream itself, using only the `resolver`
directive and never the search domains in /etc/resolv.conf. That cost two
workarounds in one script: rewriting the resolver address for rootless podman
(Docker's 127.0.0.11 is wrong there), and injecting a full FQDN so the bare `bff`
name could resolve on Kubernetes at all.

Caddy dials its upstream per request through the system resolver, which reads
nameserver *and* search domains, so `reverse_proxy bff:8080` resolves on every
engine with no per-engine configuration — and it still starts before the BFF
exists and picks up its restarts. Both workarounds are deleted with the script.

Routing uses mutually-exclusive `handle` blocks, not a bare `try_files`: Caddy
sorts rewrites *before* reverse_proxy, so a top-level SPA fallback would rewrite
every API path to /index.html before the proxy saw it.
This commit is contained in:
not
2026-09-04 17:51:21 +02:00
parent 51d99855d1
commit 4edcf00267
23 changed files with 133 additions and 173 deletions
+22
View File
@@ -0,0 +1,22 @@
:80 {
# Same-origin API: behandelaars authenticate against the medewerker realm; the BFF validates it
# for /behandel/* (S-12c).
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
#
# No `resolver` stanza is needed: Caddy dials the upstream per
# request through the system resolver, so it starts before the BFF is up, picks up
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
handle /behandel/* {
reverse_proxy bff:8080
}
# The Angular app. Client-side routing: an unknown path serves index.html.
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
+6 -9
View File
@@ -1,4 +1,4 @@
# Multi-stage build for the behandel portal (Angular → nginx).
# Multi-stage build for the behandel portal (Angular → Caddy).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
@@ -13,15 +13,12 @@ COPY apps/behandel apps/behandel
COPY libs libs
RUN pnpm nx build behandel
FROM nginx:1.27-alpine AS runtime
COPY apps/behandel/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/behandel/browser /usr/share/nginx/html
FROM caddy:2-alpine AS runtime
COPY apps/behandel/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /src/dist/apps/behandel/browser /usr/share/caddy
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/caddy/config.json
EXPOSE 80
-24
View File
@@ -1,24 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the behandel endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /behandel/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export interface RuntimeConfig {
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* the api-client actually calls (same-origin via the Caddy proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/behandel/` is secured; the app calls no other endpoint group.
*/
+21
View File
@@ -0,0 +1,21 @@
:80 {
# Same-origin API: beheerders use the same medewerker realm as behandel (S-15a).
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
#
# No `resolver` stanza is needed: Caddy dials the upstream per
# request through the system resolver, so it starts before the BFF is up, picks up
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
handle /beheer/* {
reverse_proxy bff:8080
}
# The Angular app. Client-side routing: an unknown path serves index.html.
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
+6 -9
View File
@@ -1,4 +1,4 @@
# Multi-stage build for the beheer portal (Angular → nginx).
# Multi-stage build for the beheer portal (Angular → Caddy).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
@@ -13,15 +13,12 @@ COPY apps/beheer apps/beheer
COPY libs libs
RUN pnpm nx build beheer
FROM nginx:1.27-alpine AS runtime
COPY apps/beheer/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/beheer/browser /usr/share/nginx/html
FROM caddy:2-alpine AS runtime
COPY apps/beheer/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /src/dist/apps/beheer/browser /usr/share/caddy
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's medewerker authority (host-consistent, ADR-0013).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
RUN printf '{ "authority": "http://keycloak:8080/realms/medewerker" }\n' > /usr/share/caddy/config.json
EXPOSE 80
-24
View File
@@ -1,24 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the beheer endpoint group to the bff service. The api-client uses
# relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the
# medewerker token (same-origin) is attached by the app's interceptor (ADR-0013).
location /beheer/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ export interface RuntimeConfig {
/**
* Route prefixes whose requests carry the medewerker token. These MUST match the **relative** URLs
* the api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on
* the api-client actually calls (same-origin via the Caddy proxy) — the interceptor matches on
* `req.url`, which stays relative, so an absolute origin would never match and the token would go
* unattached. Only `/beheer/` is secured; the app calls no other endpoint group.
*/
+21
View File
@@ -0,0 +1,21 @@
:80 {
# Same-origin API: the public register is anonymous, but still reads through the BFF (S-09).
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
#
# No `resolver` stanza is needed: Caddy dials the upstream per
# request through the system resolver, so it starts before the BFF is up, picks up
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
handle /openbaar/* {
reverse_proxy bff:8080
}
# The Angular app. Client-side routing: an unknown path serves index.html.
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
+4 -8
View File
@@ -1,4 +1,4 @@
# Multi-stage build for the openbaar portal (Angular → nginx).
# Multi-stage build for the openbaar portal (Angular → Caddy).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
@@ -13,13 +13,9 @@ COPY apps/openbaar apps/openbaar
COPY libs libs
RUN pnpm nx build openbaar
FROM nginx:1.27-alpine AS runtime
COPY apps/openbaar/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/nginx/html
FROM caddy:2-alpine AS runtime
COPY apps/openbaar/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /src/dist/apps/openbaar/browser /usr/share/caddy
# No runtime config: the openbaar register is anonymous (no OIDC authority to inject).
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
EXPOSE 80
-23
View File
@@ -1,23 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the anonymous openbaar endpoint group to the bff service. The api-client
# uses relative URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS.
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
+1 -1
View File
@@ -8,7 +8,7 @@ import { appRoutes } from './app.routes';
/**
* The openbaar register is a public, anonymous read: no DigiD, no auth interceptor. The app is served
* same-origin as the BFF (nginx proxies /openbaar), so the api-client's relative calls stay same-origin.
* same-origin as the BFF (Caddy proxies /openbaar), so the api-client's relative calls stay same-origin.
*/
export const appConfig: ApplicationConfig = {
providers: [
-17
View File
@@ -1,17 +0,0 @@
#!/bin/sh
# Point nginx's reverse-proxy `resolver` at THIS container's real DNS server.
#
# The portal nginx configs use a variable proxy_pass, which needs a `resolver` so the BFF hostname is
# resolved at request time (nginx can start before the BFF is up). The config hardcodes Docker's
# embedded DNS (127.0.0.11) — correct on Docker/Docker Desktop, but rootless podman uses a
# network-specific address (aardvark, e.g. 10.89.0.1), so proxied calls 502 there. Read the actual
# nameserver from /etc/resolv.conf and substitute it, so the reverse proxy works on any engine.
#
# Runs from the nginx image's /docker-entrypoint.d/ before nginx starts. On Docker the nameserver IS
# 127.0.0.11, so the substitution is a no-op. Guarded (no `set -e`) so it's safe whether the nginx
# entrypoint executes or sources it.
ns="$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf 2>/dev/null)"
if [ -n "$ns" ] && [ "$ns" != "127.0.0.11" ]; then
sed -i "s/resolver 127\.0\.0\.11/resolver $ns/" /etc/nginx/conf.d/default.conf 2>/dev/null || true
echo "portal-nginx-resolver: set resolver to $ns"
fi
+26
View File
@@ -0,0 +1,26 @@
:80 {
# Same-origin API: the api-client uses relative URLs, so the browser calls this origin and Caddy
# forwards to the BFF — no CORS, and the DigiD token is attached by the app interceptor
# (S-08d/ADR-0010).
# `handle` blocks are mutually exclusive and matched most-specific-first, so the
# SPA fallback below can never swallow an API call — unlike a bare `try_files`,
# which Caddy sorts *before* reverse_proxy and would rewrite it to /index.html.
#
# No `resolver` stanza is needed: Caddy dials the upstream per
# request through the system resolver, so it starts before the BFF is up, picks up
# its restarts, and honours the DNS search domains in /etc/resolv.conf — which is
# what lets the bare `bff` name resolve on Kubernetes as well as under compose.
handle /self-service/* {
reverse_proxy bff:8080
}
handle /openbaar/* {
reverse_proxy bff:8080
}
# The Angular app. Client-side routing: an unknown path serves index.html.
handle {
root * /usr/share/caddy
try_files {path} /index.html
file_server
}
}
+6 -9
View File
@@ -1,4 +1,4 @@
# Multi-stage build for the self-service portal (Angular → nginx).
# Multi-stage build for the self-service portal (Angular → Caddy).
# Build context is the repo root (the app needs the pnpm workspace + libs). See infra/docker-compose.yml.
FROM node:24-slim AS build
WORKDIR /src
@@ -13,15 +13,12 @@ COPY apps/self-service apps/self-service
COPY libs libs
RUN pnpm nx build self-service
FROM nginx:1.27-alpine AS runtime
COPY apps/self-service/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /src/dist/apps/self-service/browser /usr/share/nginx/html
FROM caddy:2-alpine AS runtime
COPY apps/self-service/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /src/dist/apps/self-service/browser /usr/share/caddy
# Compose-time OIDC config: the browser (Playwright, on the compose network) reaches Keycloak by
# service name, so the token issuer matches the BFF's authority (host-consistent, ADR-0010).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/nginx/html/config.json
# Make the reverse-proxy resolver engine-portable (Docker 127.0.0.11 vs podman aardvark); runs from
# the nginx image's /docker-entrypoint.d before nginx starts.
COPY apps/portal-nginx-resolver.sh /docker-entrypoint.d/40-resolver.sh
RUN chmod +x /docker-entrypoint.d/40-resolver.sh
# Kubernetes mounts a ConfigMap over this file with the node address instead (ADR-0033).
RUN printf '{ "authority": "http://keycloak:8080/realms/digid" }\n' > /usr/share/caddy/config.json
EXPOSE 80
-29
View File
@@ -1,29 +0,0 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
# Resolve the BFF via Docker's embedded DNS at request time (variable proxy_pass), so nginx starts
# even before the BFF is up and picks up restarts — instead of failing to load the config.
resolver 127.0.0.11 ipv6=off valid=30s;
# Same-origin API: proxy the BFF endpoint groups to the bff service. The api-client uses relative
# URLs, so the browser calls this origin and nginx forwards to the BFF — no CORS, and the DigiD
# token (same-origin) is attached by the app's interceptor (S-08d/ADR-0010).
location /self-service/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
location /openbaar/ {
set $bff http://bff:8080;
proxy_pass $bff;
proxy_set_header Host $host;
}
# SPA fallback — Angular client-side routing.
location / {
try_files $uri $uri/ /index.html;
}
}
+1 -1
View File
@@ -15,7 +15,7 @@ export interface RuntimeConfig {
/**
* Route prefixes whose requests carry the DigiD token. These MUST match the **relative** URLs the
* api-client actually calls (same-origin via the nginx proxy) — the interceptor matches on `req.url`,
* api-client actually calls (same-origin via the Caddy proxy) — the interceptor matches on `req.url`,
* which stays relative, so an absolute origin would never match and the token would go unattached.
* `/openbaar/` is deliberately excluded: it is the anonymous public register.
*/