S-24/#25 · Helm chart + Kubernetes deployment, and Caddy for the portals (#166) #167

Merged
not merged 6 commits from feat/25-helm-kubernetes-caddy into main 2026-09-10 08:53:59 +00:00
23 changed files with 133 additions and 173 deletions
Showing only changes of commit 4edcf00267 - Show all commits
+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.
*/
+3 -3
View File
@@ -510,7 +510,7 @@ services:
networks: [cg]
# ── Portals (S-08/S-09/S-12) ──────────────────────────────────────────────
# nginx serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# Caddy serves each Angular app and reverse-proxies its endpoint group to the BFF (same-origin).
# The images bake config.json with the compose authority (keycloak:8080), which a HOST browser
# can't resolve — so here we bind-mount a config.json pointing at the host-published localhost:8180
# (matching KC_HOSTNAME). openbaar is anonymous and needs no config.
@@ -522,7 +522,7 @@ services:
ports:
- "8140:80"
volumes:
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
- ./local-config/self-service.config.json:/usr/share/caddy/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
@@ -562,7 +562,7 @@ services:
ports:
- "8142:80"
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
healthcheck:
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
+8 -8
View File
@@ -496,7 +496,7 @@ services:
networks: [cg]
# ── Self-Service portal (S-08d) ────────────────────────────────────────────
# nginx serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
# Caddy serves the Angular app and reverse-proxies /self-service + /openbaar to the BFF
# (same-origin, no CORS). The Playwright e2e drives it inside this network so the DigiD
# token issuer (keycloak:8080) matches the BFF's authority (ADR-0010).
self-service:
@@ -507,7 +507,7 @@ services:
ports:
- "8140:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
@@ -520,7 +520,7 @@ services:
condition: service_started
networks: [cg]
# The openbaar (public) register portal: nginx serves the Angular app and reverse-proxies
# The openbaar (public) register portal: Caddy serves the Angular app and reverse-proxies
# /openbaar to the BFF. Anonymous — no DigiD, no Keycloak dependency (S-09).
openbaar:
build:
@@ -530,7 +530,7 @@ services:
ports:
- "8141:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
@@ -541,7 +541,7 @@ services:
condition: service_healthy
networks: [cg]
# The behandel portal: nginx serves the Angular app and reverse-proxies /behandel to the BFF.
# The behandel portal: Caddy serves the Angular app and reverse-proxies /behandel to the BFF.
# Behandelaars log in against the Keycloak medewerker realm (ADR-0013; S-12).
behandel:
build:
@@ -551,7 +551,7 @@ services:
ports:
- "8142:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
@@ -564,7 +564,7 @@ services:
condition: service_started
networks: [cg]
# The beheer portal: nginx serves the Angular app and reverse-proxies /beheer to the BFF.
# The beheer portal: Caddy serves the Angular app and reverse-proxies /beheer to the BFF.
# Beheerders log in against the Keycloak medewerker realm (same realm as behandel, S-15a).
beheer:
build:
@@ -574,7 +574,7 @@ services:
ports:
- "8143:80"
healthcheck:
# 127.0.0.1, not localhost: nginx listens on IPv4 only, but localhost resolves to ::1 first.
# 127.0.0.1, not localhost: keeps the check on the interface Caddy is published on.
test: ["CMD-SHELL", "wget -q -O /dev/null http://127.0.0.1/ || exit 1"]
interval: 5s
timeout: 3s
+3 -3
View File
@@ -10,11 +10,11 @@ services:
KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true"
self-service:
volumes:
- ./local-config/self-service.config.json:/usr/share/nginx/html/config.json:ro,z
- ./local-config/self-service.config.json:/usr/share/caddy/config.json:ro,z
behandel:
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
# beheer is the same medewerker realm as behandel, so it reuses behandel's config verbatim.
beheer:
volumes:
- ./local-config/behandel.config.json:/usr/share/nginx/html/config.json:ro,z
- ./local-config/behandel.config.json:/usr/share/caddy/config.json:ro,z
+1 -1
View File
@@ -15,7 +15,7 @@ export interface DigiadAuthOptions {
redirectUrl: string;
/**
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* **relative** URLs (same-origin via the Caddy proxy), so these must be relative path prefixes
* (e.g. `/self-service/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
@@ -10,7 +10,7 @@ export interface MedewerkerAuthOptions {
redirectUrl: string;
/**
* Route prefixes whose requests get the bearer token attached. The api-client calls the BFF with
* **relative** URLs (same-origin via the nginx proxy), so these must be relative path prefixes
* **relative** URLs (same-origin via the Caddy proxy), so these must be relative path prefixes
* (e.g. `/behandel/`) — angular-auth-oidc-client matches `req.url.startsWith(route)`, and a
* relative `req.url` never starts with an absolute origin.
*/
+1 -1
View File
@@ -98,7 +98,7 @@ test('DigiD submit → public INGEDIEND → documenten → behandelaar goedkeurt
await expect(goedkeuren).toBeVisible({ timeout: 30_000 });
// Click and wait for the decide POST to finish (204) BEFORE leaving the page. `click()` only
// dispatches the request; navigating away immediately cancels it in flight (nginx logs a 499) and
// dispatches the request; navigating away immediately cancels it in flight (the proxy logs a client-cancelled request) and
// the decision never reaches the domain — so the registration would stay INGEDIEND.
const decided = staff.waitForResponse(
(r) =>