← Übersicht  ·  Skripte & Dateien  ·  cloud-host · OpenShift · Frontend

SPA-Frontends auf OpenShift

Das Modell steht in den Atlas-Diagrammen J1–J4. library-frontend ist ein ng build-Output, serviert von nginx – diese Anleitung baut das real nach: ein Nicht-Root-nginx mit einer edge-Route ausliefern, Pfad-Routing gegen CORS live vergleichen, ein echtes PKCE-Codepaar lokal erzeugen, und die Laufzeit-Config per env.js aus einer ConfigMap laden statt sie ins Build zu backen. EX280-relevant.

Stand: 4. September 2026 OKD 4.x / OCP 4.x ns library

Voraussetzung: Zugang zum Namespace library. Statt eines echten ng build-Outputs steht eine winzige Hand-HTML-Datei stellvertretend für „die gebaute SPA“ – die Ausliefer- und Config-Mechanik ist real und identisch. Schritt 3 braucht keinen Cluster, nur openssl lokal.

1 Eine SPA per nginx ausliefern

Ein gebautes Frontend ist nur ein Ordner statischer Dateien – ausgeliefert von einem winzigen Nicht-Root-nginx.

Ein gebautes Angular- oder React-Frontend ist nur ein Ordner statischer Dateien – HTML, JS, CSS, keine Server-Logik. Zwei Wege, sie auszuliefern: ein winziges nginx-Image mit den Assets oder ein nodejs-S2I-Build. Für eine reine SPA gewinnt nginx. Davor eine edge-Route (B4).

Ein gebautes Frontend sind nur statische Dateien – irgendwer muss sie servieren. Build (außerhalb, CI) ng build --configuration=prod erzeugt dist/: index.html, *.js, *.css keine Serverlogik, keine Umgebungslese Node nur zum Bauen, nicht zur Laufzeit Config kommt erst zur Laufzeit (J4) Weg A – nginx-Container (empfohlen) dist/ nach /usr/share/nginx/html kopieren eigene nginx.conf: try_files $uri /index.html (History-Routing), Port 8080 statt 80 (SCC, A7), gzip, Cache-Header ubi9/nginx-124 / nginxinc/nginx-unprivileged laufen als Nicht-Root Image ~20–40 MB, kein Node, startet in Millisekunden Weg B – nodejs-S2I ubi9/nodejs-20 · assemble: npm ci && npm run build run: ein kleiner Node-Server (serve, http-server) ein Build-Schritt (C3), aber Node läuft zur Laufzeit größer, mehr RAM, nur sinnvoll bei SSR / Next.js Service ClusterIP, port 8080 (B3) Selector auf die nginx-Pods edge-Route ↗ library-frontend.apps.<cluster> TLS endet am Router, dahinter HTTP Browser lädt index.html, dann die JS-Bundles von da an läuft alles im Client API-Aufrufe: J2 · Login: J3 dieselben Assets, anderes Laufzeit-Image die Stolpersteine Port 80 im Image → Permission denied unter restricted-v2 (A7) – auf 8080 kein try_files-Fallback → Deep-Links (/buch/42) geben 404 Standard-nginx-Image will nach /var/cache/nginx schreiben → emptyDir oder unprivileged-Image (H2) Cache-Header: index.html nie cachen, gehashte Bundles lange cachen Bibliothek / cloud-host library-frontend = ng build-Output, serviert von ubi9/nginx auf 8080 gebaut von der CI außerhalb (E1), als kleines Image nach ghcr.io eine edge-Route, die Backend-URL kommt per env.js (J4) Login gegen Keycloak aus dem Browser (J3) Merksatz: statische SPA → kleines nginx-Image auf 8080 mit try_files-Fallback, davor eine edge-Route nodejs-S2I nur, wenn wirklich ein Node-Server gebraucht wird (SSR, API-Routen) – sonst unnötiger Ballast OCP-Unterschied: keiner – nginx und Node laufen wie überall. OpenShift zwingt nur zum Nicht-Root-Port (A7) und liefert die Route (B4).
Statische Dateien brauchen einen Server. Ein ng build liefert nur dist/ – HTML, JS, CSS. Am besten ein winziges nginx-Image (unter 40 MB) mit den Assets, einer try_files-Regel für das SPA-Routing und Port 8080 statt 80 (SCC, A7). Ein nodejs-S2I-Build lohnt nur, wenn wirklich ein Node-Server zur Laufzeit gebraucht wird. Davor eine edge-Route (B4) auf den Service. Gebaut wird außerhalb, die Laufzeit-Config kommt separat (J4).
  • ein gebautes Frontend (ng build / npm run build) ist nur ein Ordner statischer Dateien (dist/) – keine Serverlogik, keine Umgebungslese
  • Weg A – nginx-Container: dist/ nach /usr/share/nginx/html, eigene nginx.conf, winziges Image, kein Node zur Laufzeit
  • die nginx.conf braucht try_files $uri /index.html für das SPA-History-Routing – sonst geben Deep-Links 404
  • Port 8080 statt 80 – unter restricted-v2 darf ein Nicht-Root-Container keinen Port unter 1024 binden (A7)
  • nimm ein Nicht-Root-Image: ubi9/nginx-124 oder nginxinc/nginx-unprivileged – das Standard-nginx-Image will nach /var/cache/nginx schreiben (H2)
  • Weg B – nodejs-S2I (ubi9/nodejs-20): assemble baut, run startet einen kleinen Node-Server – schwerer, weil Node zur Laufzeit läuft
  • für eine reine statische SPA gewinnt nginx – nodejs-S2I nur bei echtem Server-Bedarf (SSR, Next.js, API-Routen)
  • die Route ist edge TLS (B4) und zeigt auf den Service (B3) auf Port 8080
  • Cache-Header: index.html nie cachen, die gehashten Bundles (main.a1b2.js) sehr lange – sonst hängen alte Clients an alten Bundles
  • die SPA außerhalb bauen (CI, E1) und ein kleines Image mit den Assets nach ghcr.io pushen – kein Build-Pod im Cluster (C2)
  • die Laufzeit-Config (Backend-URL, Keycloak-Realm) kommt nicht ins Build – sie wird per env.js zur Laufzeit geladen (J4)
  • Bibliothek: library-frontend ist ein ng build-Output, serviert von ubi9/nginx auf 8080, mit einer edge-Route
  • OCP-Unterschied: keiner – nginx und Node laufen wie überall. OpenShift verlangt nur den Nicht-Root-Port und liefert die Route (B4)
Laptop · Bash
cat <<'EOF' | oc apply -f -
apiVersion: v1
kind: ConfigMap
metadata:
  name: demo-spa-dist
  namespace: library
data:
  index.html: |
    <!doctype html><html><body><h1>demo-spa</h1>
    <p>Deep-Link-Test: <a href="/kunde/42">/kunde/42</a></p></body></html>
  nginx.conf: |
    server {
      listen 8080;
      root /usr/share/nginx/html;
      location / { try_files $uri /index.html; }
    }
EOF

cat <<'EOF' | oc apply -f -
apiVersion: apps/v1
kind: Deployment
metadata:
  name: demo-spa
  namespace: library
spec:
  replicas: 1
  selector: { matchLabels: { app: demo-spa } }
  template:
    metadata: { labels: { app: demo-spa } }
    spec:
      containers:
        - name: nginx
          image: registry.access.redhat.com/ubi9/nginx-124:latest
          ports: [{ containerPort: 8080 }]
          volumeMounts:
            - { name: dist, mountPath: /usr/share/nginx/html/index.html, subPath: index.html }
            - { name: dist, mountPath: /etc/nginx/nginx.conf.d/spa.conf, subPath: nginx.conf }
      volumes:
        - name: dist
          configMap: { name: demo-spa-dist }
EOF
oc expose deployment/demo-spa -n library --port=8080
oc create route edge demo-spa -n library --service=demo-spa

HOST=$(oc get route demo-spa -n library -o jsonpath='{.spec.host}')
curl -sk "https://$HOST/" | head -3
curl -sk -o /dev/null -w "%{http_code}\n" "https://$HOST/kunde/42"
# 200 statt 404 - try_files reicht den Deep-Link an index.html weiter

ubi9/nginx-124 läuft schon als Nicht-Root auf Port 8080 – das Standard-nginx-Image würde nach /var/cache/nginx schreiben wollen und unter restricted-v2 scheitern (H2). Ohne try_files $uri /index.html gäbe /kunde/42 ein echtes 404, weil auf dem Server keine solche Datei existiert – das Routing macht Angular/React erst im Browser.

2 Pfad-Routing gegen CORS live vergleichen

Zwei Hosts brauchen CORS, ein Host mit Pfad-Routing nicht – live am Preflight sichtbar.

Wie erreicht die SPA das Backend? Entweder zwei Hosts (Frontend und API je eine Route) – dann redet der Browser cross-origin und die API braucht CORS. Oder ein Host mit Pfad-Routing (/ zum Frontend, /api zur API) – same-origin, kein CORS, ein Zertifikat. Pfad-Routing ist einfacher, koppelt aber die Deploys.

Zwei Hosts → CORS. Ein Host mit Pfaden → same-origin, kein CORS. Option A – zwei Hosts, zwei Routen Browser lädt SPA von library-frontend.apps SPA ruft die API auf https://api-gateway.apps/… andere Origin → der Browser macht zuerst eine OPTIONS-Preflight-Anfrage → die api-gateway muss CORS-Header senden: Access-Control-Allow-Origin: https://library-frontend.apps Access-Control-Allow-Methods / -Headers / -Credentials fehlt einer → der Browser blockt die Antwort (die App sieht einen Netzwerkfehler) Cookies cross-site: SameSite=None + Secure + Allow-Credentials: true Vorteil: klare Grenzen, unabhängige Deploys · Nachteil: CORS-Pflege, zwei Zertifikate die SPA muss die API-URL kennen → env.js (J4) Option B – ein Host, Pfad-Routing library.apps.<cluster> eine Route je Pfad (edge, spec.path, B4): / → Service library-frontend:8080 /api → Service api-gateway:8080 Browser sieht eine Originkein CORS, kein Preflight ein Zertifikat, Cookies (same-site) funktionieren einfach der Router matcht den längsten Pfad zuerst · ggf. rewrite-target die SPA ruft einfach /api/… (relativ) – keine URL-Config nötig Nachteil: Frontend und API hängen an einem Host und einer Route-Gruppe Alternative: ein Reverse-Proxy im Frontend-Pod (nginx location /api) was CORS wirklich ist eine Browser-Regel: JS darf eine Antwort von einer anderen Origin nur lesen, wenn diese es per Header erlaubt der Server ist nicht „geschützt“ – curl ignoriert CORS. Es schützt den Nutzer vor fremden Seiten Preflight (OPTIONS) bei „nicht-einfachen“ Requests (JSON-Body, Authorization-Header) Wildcard Allow-Origin: * geht nicht zusammen mit Allow-Credentials: true Bibliothek / cloud-host getrennte Hosts: library-frontend.apps + api-gateway.apps CORS auf der api-gateway (Spring @CrossOrigin / WebFlux-Config) die API-Basis-URL kommt per env.js in die SPA (J4) Bearer-Token statt Cookie → CORS ohne credentials (J3) Merksatz: gleicher Host / Pfad-Routing → kein CORS, einfachste Lösung. Getrennte Hosts → CORS-Header auf der API pflegen Bearer-Token (J3) statt Session-Cookie macht CORS deutlich einfacher – kein Allow-Credentials, kein SameSite OCP-Unterschied: keiner – CORS ist Browser + App. OpenShift liefert nur die Route(n) (B4) und das Pfad-Routing über spec.path.
Eine Origin oder zwei. Bekommen Frontend und API je einen Host, redet die SPA cross-origin und die API muss CORS-Header senden (Access-Control-Allow-Origin …) – sonst blockt der Browser die Antwort. Teilen sie sich einen Host mit Pfad-Routing (/ und /api, per spec.path auf der Route, B4), ist alles same-origin: kein CORS, ein Zertifikat, Cookies funktionieren. CORS schützt den Nutzer, nicht den Server – ein Bearer-Token (J3) macht es einfacher.
  • Option A – zwei Hosts: Frontend und API je eine Route – die SPA ruft eine andere Origin, also cross-origin
  • cross-origin → CORS: der Browser macht erst eine OPTIONS-Preflight, die API muss Access-Control-Allow-Origin / -Methods / -Headers senden
  • fehlt ein CORS-Header, blockt der Browser die Antwort – die App sieht nur einen Netzwerkfehler, der Server hat trotzdem geantwortet
  • Option B – ein Host, Pfad-Routing: eine Route je Pfad (edge, spec.path, B4), / zum Frontend-Service, /api zur api-gateway (B3)
  • same-origin → kein CORS, kein Preflight, ein Zertifikat, Cookies funktionieren – die SPA ruft einfach relativ /api/…
  • Abwägung: Pfad-Routing koppelt die zwei Deploys unter einem Host, getrennte Hosts sind sauberere Grenzen (aber CORS-Pflege)
  • CORS ist eine Browser-Regelcurl ignoriert sie. Sie schützt den Nutzer vor fremden Seiten, nicht den Server
  • Wildcard Allow-Origin: * geht nicht zusammen mit Allow-Credentials: true – bei Cookies den Origin explizit setzen
  • Cookies cross-site brauchen SameSite=None mit Secure plus Allow-Credentials: true – ein Bearer-Token (J3) umgeht das ganz
  • Alternative zu beidem: ein Reverse-Proxy im Frontend-Pod (nginx location /api { proxy_pass http://api-gateway:8080; }) – die SPA sieht nur ihre eigene Origin
  • bei getrennten Hosts muss die SPA die API-URL kennen – nicht ins Build backen, sondern per env.js (J4)
  • Bibliothek: getrennte Hosts + CORS auf der api-gateway (Spring @CrossOrigin), Bearer-Token statt Cookie, API-URL per env.js
  • OCP-Unterschied: keiner – CORS ist Browser + App. OpenShift liefert die Route(n) und das Pfad-Routing über spec.path
Laptop · Bash
# eine kleine API ohne CORS-Header, als zweiter Host:
oc create deployment demo-api -n library --image=registry.k8s.io/e2e-test-images/agnhost:2.39 \
  -- /agnhost netexec --http-port=8080
oc expose deployment/demo-api -n library --port=8080
oc create route edge demo-api -n library --service=demo-api

API_HOST=$(oc get route demo-api -n library -o jsonpath='{.spec.host}')
FRONT_HOST=$(oc get route demo-spa -n library -o jsonpath='{.spec.host}')

# Preflight simulieren, als kaeme der Request vom Frontend-Host:
curl -sk -X OPTIONS "https://$API_HOST/hostname" \
  -H "Origin: https://$FRONT_HOST" \
  -H "Access-Control-Request-Method: GET" -i | grep -i "access-control"
# leer - keine CORS-Header, der Browser wuerde die Antwort blocken

# jetzt Pfad-Routing: dieselbe API zusaetzlich unter /api auf dem Frontend-Host
oc patch route demo-spa -n library --type=merge \
  -p '{"spec":{"path":"/"}}'
cat <<EOF | oc apply -f -
apiVersion: route.openshift.io/v1
kind: Route
metadata:
  name: demo-spa-api
  namespace: library
spec:
  host: $FRONT_HOST
  path: /api
  to: { kind: Service, name: demo-api }
  port: { targetPort: 8080 }
  tls: { termination: edge }
EOF
curl -sk "https://$FRONT_HOST/api/hostname"
# same-origin - kein Preflight noetig, kein CORS-Header noetig

curl ignoriert CORS – die Regel gilt nur im Browser. Der Beweis hier ist strukturell: ohne Access-Control-Allow-Origin im Preflight würde ein echter Browser die Antwort verwerfen, obwohl der Server geantwortet hat. Mit Pfad-Routing entfällt die Frage komplett, weil beide Aufrufe von derselben Origin ausgehen.

3 Ein echtes PKCE-Codepaar erzeugen

Die SPA ist ein public OIDC-Client ohne Secret – PKCE ersetzt es durch ein selbst erzeugtes Codepaar.

Die SPA ist ein public OIDC-Client – sie hat kein Client-Secret, denn alles im Browser-JS ist öffentlich. Sie nutzt den Authorization-Code-Flow mit PKCE: eine Kette von Redirects zwischen Browser, Keycloak und SPA, an deren Ende ein access_token (JWT) steht. Die api-gateway prüft nur noch die Signatur.

Kein Secret im Browser – PKCE ersetzt es durch ein Einmal-Secret pro Login. SPA im Browser public client, kein Secret erzeugt code_verifier + code_challenge = S256(verifier) state gegen CSRF, nonce Keycloak ↗ eigene Route, der IdP der App (nicht der Cluster-OAuth, A4) Realm bibliothek, Client library-frontend Endpoints: /authorize, /token, /logout, JWKS api-gateway bekommt Authorization: Bearer <jwt> prüft die Signatur gegen Keycloaks JWKS plus exp, aud, iss – keine Keycloak-Anfrage nötig Rollen aus dem Token → RBAC in der App 1 3 4 5 1  Redirect zu /authorize?response_type=code&code_challenge=E9m2XhV_aB3-Qk7p&state=f8a2c91d 2  Browser landet bei Keycloak → Nutzer meldet sich an (Passwort, MFA, Social…) 3  Redirect zurück zur redirect_uri?code=SplxlOBeZQQYbYS6&state=f8a2c91d (nur ein kurzer Code, kein Token) 4  SPA POSTet code + code_verifier an /tokenaccess_token (JWT) + id_token + refresh_token 5  jede API-Anfrage trägt Authorization: Bearer <access_token> was PKCE schützt ohne Secret könnte ein Angreifer den code abfangen und selbst einlösen mit PKCE: /token prüft, dass S256(code_verifier) == code_challenge der verifier hat nie den Browser verlassen → nur diese SPA kann den Code einlösen nie den Implicit-Flow (response_type=token) – abgekündigt, Token in der URL was matchen muss (alles öffentlich → env.js, J4) redirect_uri exakt, im Keycloak-Client registriert (auch die Logout-URL) web_origins im Client → CORS für den /token-Call (J2) realm + client-id + Keycloak-URL → in die SPA per env.js Bibliotheken: keycloak-js, oidc-client-ts, angular-auth-oidc-client Token-Ablage: im Speicher am sichersten, sessionStorage geht – nie localStorage für Tokens (XSS). Refresh per refresh_token oder stillem prompt=none-iframe Merksatz: SPA holt sich per Redirect + PKCE ein JWT, die API prüft nur die Signatur – kein Server-State, kein Session-Cookie OCP-Unterschied: keiner – das ist reines OIDC. Keycloak läuft als App im Cluster (eigene Route, B4), unabhängig vom Cluster-OAuth-Server (A4).
Der Redirect-Tanz. Die SPA hat kein Secret, also erzeugt sie pro Login ein Einmal-Secret (PKCE): code_verifier bleibt im Browser, code_challenge geht mit zum /authorize. Keycloak schickt nach dem Login einen kurzen code zurück, den die SPA zusammen mit dem code_verifier gegen ein access_token (JWT) tauscht. Die api-gateway prüft nur die Signatur gegen Keycloaks JWKS – kein Server-State. redirect_uri, web_origins, Realm und Client-id müssen exakt passen (env.js, J4).
  • die SPA ist ein public OIDC-Clientkein Client-Secret, weil alles im Browser-JS öffentlich ist
  • sie nutzt den Authorization-Code-Flow mit PKCE: code_verifier bleibt im Browser, code_challenge = S256(verifier) geht mit
  • Schritt 1: Redirect zu Keycloaks /authorize mit response_type=code, client_id, redirect_uri, code_challenge, state, nonce
  • Schritt 2: der Nutzer meldet sich bei Keycloak an – Keycloak ist hier der IdP der App (eigene Route, B4), nicht der Cluster-OAuth (A4)
  • Schritt 3: Keycloak leitet zurück zur registrierten redirect_uri mit ?code=SplxlOBeZQQYbYS6&state=f8a2c91d – nur ein kurzer Code, kein Token
  • Schritt 4: die SPA POSTet code + code_verifier an /tokenaccess_token (JWT) + id_token + refresh_token
  • Schritt 5: jede API-Anfrage trägt Authorization: Bearer <access_token>, die api-gateway prüft die Signatur gegen die JWKS (kein Keycloak-Call)
  • PKCE schützt davor, dass ein abgefangener code von jemand anderem eingelöst wird – nur wer den verifier hat, kommt an das Token
  • nie der Implicit-Flow (response_type=token) – abgekündigt, Token landet in der URL-History
  • was matchen muss: redirect_uri exakt registriert, web_origins für CORS des /token-Calls (J2), Realm + Client-id
  • Realm, Client-id und Keycloak-URL sind öffentlich → sie kommen per env.js in die SPA (J4), nicht ins Build
  • Token-Ablage: im Speicher am sichersten, sessionStorage geht, nie localStorage wegen XSS – Refresh per refresh_token oder stillem prompt=none-iframe
  • Logout = Keycloaks /logout aufrufen und die lokalen Tokens löschen
  • OCP-Unterschied: keiner – reines OIDC. Keycloak läuft als App im Cluster, unabhängig vom Cluster-OAuth-Server (A4)
Laptop · Bash
# code_verifier: 32 zufaellige Bytes, URL-safe base64
VERIFIER=$(openssl rand -base64 32 | tr '+/' '-_' | tr -d '=')
echo "code_verifier: $VERIFIER"

# code_challenge: S256(verifier), ebenfalls URL-safe base64
CHALLENGE=$(printf '%s' "$VERIFIER" | openssl dgst -sha256 -binary | openssl base64 | tr '+/' '-_' | tr -d '=')
echo "code_challenge: $CHALLENGE"

# die Authorize-URL, die die SPA im Browser aufrufen wuerde (Schritt 1 des Flows):
echo "https://keycloak.example/realms/library/protocol/openid-connect/auth?\
response_type=code&client_id=library-frontend&\
redirect_uri=https://\$FRONT_HOST/callback&\
code_challenge=$CHALLENGE&code_challenge_method=S256&state=demo&nonce=demo"

Ab hier übernimmt ein echter Browser plus eine echte Keycloak-Instanz – der Nutzer meldet sich an, Keycloak leitet mit einem kurzen code zurück, die SPA tauscht code + code_verifier gegen ein access_token. PKCE schützt genau hier: ein abgefangener code allein nützt nichts, ohne den verifier, den nur diese SPA-Instanz kennt.

Nie der Implicit-Flow response_type=token ist abgekündigt – das Token landet direkt in der URL und damit in der Browser-History. Token-Ablage im Speicher, nie in localStorage wegen XSS.

4 env.js aus einer ConfigMap laden

Build once, deploy everywhere: dasselbe Image, andere Laufzeit-Config – ohne Rebuild.

Eine statische SPA wird einmal gebaut und kann zur Laufzeit keine Environment-Variablen lesen – im Browser gibt es kein process.env. Das env.js-Muster löst das: das Build lädt vor dem App-Bundle ein env.js, das window.__APP_CONFIG__ setzt. Diese Datei ist nicht im Image – sie kommt beim Start aus einer ConfigMap (B8), als Volume-Mount. So läuft dasselbe Image überall mit anderer Config, ohne Rebuild.

Ein Image, viele Umgebungen – die Config kommt erst beim Start dazu. der falsche Weg API-URL und Keycloak-URL hart ins Bundle schreiben (environment.prod.ts) → jede Umgebung braucht ihr eigenes Image ein falscher Wert = neu bauen, Dev und Prod driften auseinander, das getestete Artefakt ist nicht das ausgelieferte Grundregel: build once, deploy everywhere – das Image ist umgebungsneutral 1 Build (kennt keine Config) npm run builddist/ index.html lädt zuerst: <script src="env.js"></script> <script src="main.[hash].js"> im Image liegt nur ein Platzhalter-env.js 2 ein Image ghcr.io/…/library-frontend nginx serviert dist/ (J1) unverändert für Dev, Stage, Prod ein Tag, ein Digest, einmal getestet 3 ConfigMap ↗ (B8) kind: ConfigMap name: frontend-config data: { env.js: | window.__APP_CONFIG__=… } pro Umgebung eine eigene ConfigMap – gleiche Keys, andere Werte 4 Pod Volume-Mount, kein env: mountPath: /usr/share/ nginx/html/env.js subPath: env.js liegt jetzt neben index.html 5 im Browser env.js läuft vor dem App-Bundle und setzt window.__APP_CONFIG__ = { apiUrl, keycloakUrl, realm } die App liest beim Start window.__APP_CONFIG__.apiUrl – kein process.env, keine Build-Konstante dieselben Werte füttern J2 (API-URL) und J3 (Keycloak-URL, Realm, Client-id) nur öffentliche Werte in env.js – es geht ungefiltert an jeden Browser, keine Secrets ConfigMap geändert? ein Volume-Mount wird nicht heiß neu geladen oc rollout restart deployment/library-frontend neue Pods bekommen die neue env.js (B8, C6) Alternative: envsubst-Entrypoint aus Pod-env, oder /config-Sidecar Bibliothek: library-frontend mountet die frontend-config-ConfigMap als env.jsapiUrl auf die api-gateway-Route, keycloakUrl auf die Keycloak-Route Merksatz: das Image ist umgebungsneutral, die ConfigMap macht es umgebungsspezifisch – erst beim Start, per Mount, nicht per Rebuild Server-Frameworks (Next.js SSR, Nuxt) brauchen das nicht – die lesen echte env-Vars im Node-Prozess. Das Muster gilt für rein statische SPAs window.__APP_CONFIG__ = { apiUrl: "https://library.apps.example.com/api", keycloakUrl: "https://sso.apps.example.com", realm: "bibliothek" }
Ein Image, viele Umgebungen. Das Build kennt keine URLs – index.html lädt vor dem App-Bundle ein env.js, das window.__APP_CONFIG__ setzt. Diese Datei ist nicht im Image, sondern kommt aus einer ConfigMap (B8), als Volume-Mount nach /usr/share/nginx/html/env.js. Dasselbe Image läuft in Dev, Stage und Prod – nur die ConfigMap unterscheidet sich. Änderung greift erst nach oc rollout restart deployment/library-frontend. In env.js gehören nur öffentliche Werte, nie Secrets.
  • eine statische SPA wird einmal gebaut und kann zur Laufzeit keine Env-Vars lesen – im Browser gibt es kein process.env
  • der falsche Weg: URLs hart ins Bundle (environment.prod.ts) → jede Umgebung braucht ihr eigenes Image, ein Fehler heißt neu bauen
  • Grundregel build once, deploy everywhere: das getestete Artefakt ist genau das, was ausgeliefert wird
  • das env.js-Muster: index.html lädt <script src="env.js"> vor dem App-Bundle
  • env.js setzt window.__APP_CONFIG__ auf { apiUrl, keycloakUrl, realm }, die App liest das beim Start
  • env.js ist nicht im Build – im Image liegt nur ein Platzhalter
  • zur Laufzeit kommt die echte Datei aus einer ConfigMap (B8), als Volume-Mount nach /usr/share/nginx/html/env.js – kein env
  • pro Umgebung eine eigene ConfigMap: gleiche Keys, andere Werte, dasselbe Image
  • Alternative: ein Entrypoint-Skript füllt per envsubst eine Vorlage aus den Pod-env-Vars, oder ein /config-Sidecar
  • ConfigMap geändert → oc rollout restart deployment/library-frontend, ein Mount wird nicht heiß neu geladen (B8, C6)
  • in env.js gehören nur öffentliche Werte – die Datei geht ungefiltert an jeden Browser, nie Secrets
  • dieselben Werte füttern J2 (API-URL fürs Pfad-Routing) und J3 (Keycloak-URL, Realm, Client-id)
  • Server-Frameworks (Next.js SSR, Nuxt) brauchen das nicht – die lesen echte env-Vars im Node-Prozess
Laptop · Bash
oc create configmap demo-spa-env -n library \
  --from-literal=env.js='window.__APP_CONFIG__ = { apiUrl: "/api", realm: "library-test" };'

oc set volume deployment/demo-spa -n library --add \
  --configmap-name=demo-spa-env --mount-path=/usr/share/nginx/html/env.js --sub-path=env.js

oc rollout status deployment/demo-spa -n library
HOST=$(oc get route demo-spa -n library -o jsonpath='{.spec.host}')
curl -sk "https://$HOST/env.js"

# dieselbe App, andere Umgebung - nur die ConfigMap aendert sich:
oc set data configmap/demo-spa-env -n library \
  --from-literal=env.js='window.__APP_CONFIG__ = { apiUrl: "/api", realm: "library-prod" };'
oc rollout restart deployment/demo-spa -n library
curl -sk "https://$HOST/env.js"
# realm hat sich geaendert - kein neues Image, kein neuer Build

Ein Mount wird nicht heiß neu geladen – deshalb der oc rollout restart nach jeder Änderung (workload-feinschliff-openshift.html). In env.js gehören nur öffentliche Werte – die Datei geht ungefiltert an jeden Browser, nie Secrets.

5 Typische Fallen

Symptommeist
Deep-Link direkt aufgerufen gibt 404try_files $uri /index.html fehlt in der nginx-Config
CrashLoopBackOff beim Standard-nginx-Imageversucht als root nach /var/cache/nginx zu schreiben – ein Nicht-Root-Image nehmen
„Network error“ im Browser, Server hat aber geantwortetfehlender CORS-Header – der Browser blockt die Antwort, nicht der Server
PKCE-Austausch scheitert mit invalid_grantcode_verifier stimmt nicht zum code_challenge, oder der code wurde schon einmal eingelöst
neue API-URL kommt nicht anConfigMap geändert, aber kein oc rollout restart – der Mount aktualisiert sich nicht von selbst

6 Wie das Projekt es macht

library-frontend ist ein ng build-Output, serviert von ubi9/nginx auf Port 8080, mit einer edge-Route. Getrennte Hosts für Frontend und api-gateway, CORS auf der api-gateway per Spring @CrossOrigin, Bearer-Token statt Cookie – damit entfällt die SameSite/Allow-Credentials-Frage aus Schritt 2 komplett. Die API-URL, das Keycloak-Realm und die Client-id kommen per env.js, nicht ins Build.

+ Die Kurzfassung

Ausliefernnur statische Dateien, Nicht-Root-nginx auf 8080, try_files für SPA-Routing
CORSsame-origin per Pfad-Routing vermeidet Preflight und Allow-Origin-Pflege komplett
Authpublic Client, Authorization-Code-Flow mit PKCE statt Client-Secret oder Implicit-Flow
Configenv.js aus einer ConfigMap zur Laufzeit – build once, deploy everywhere
⌂ Cockpit