Deployment
Ein gebautes Sachbearbeiter-Cockpit ist eine statische SPA plus zwei Dinge, die es zur Laufzeit
braucht: die authConfig.json
und einen Proxy auf die ActaNova-API.
Beides muss pro Umgebung anders sein können, ohne neu zu bauen — sonst
brauchst du für Test und Produktion zwei Images.
Diese Seite beschreibt den Weg, den das Demo-Projekt geht: ein nginx-Container, dessen Proxy-Ziel aus einer Umgebungsvariable kommt.
Die Idee in einem Bild
docker run -e ACTANOVA_BASE_URL=https://actanova.example.com …
│
▼
Container-Start: envsubst rendert die NGINX-Vorlage
/etc/nginx/templates/default.conf.template
│ ${ACTANOVA_BASE_URL} wird eingesetzt
▼
/etc/nginx/conf.d/default.conf → nginx startet
Damit ist ein Image für alle Umgebungen gültig. Was sich unterscheidet, sind
nur die Umgebungsvariable und die gemountete authConfig.json.
Das Dockerfile
Zwei Stufen: bauen mit Node, ausliefern mit nginx.
# ---- Build stage ----
FROM node:22-alpine AS build
WORKDIR /app
# Manifeste zuerst -> Layer-Caching für `npm ci`.
# .npmrc setzt die @gentics-Registry (kein Token), muss aber vorhanden sein.
COPY package.json package-lock.json .npmrc ./
RUN npm ci
COPY . .
RUN npm run build # tsc -b && vite build -> /app/dist
# ---- Runtime stage ----
FROM nginxinc/nginx-unprivileged:1.27-alpine
USER root
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx/default.conf.template /etc/nginx/templates/default.conf.template
# OpenShift startet Container mit ZUFÄLLIGER UID, aber immer in Gruppe GID 0.
RUN chgrp -R 0 /etc/nginx /var/cache/nginx /tmp /usr/share/nginx/html \
&& chmod -R g=u /etc/nginx /var/cache/nginx /tmp /usr/share/nginx/html
ENV ACTANOVA_BASE_URL=""
ENV NGINX_ENVSUBST_FILTER=ACTANOVA_
USER nginx
EXPOSE 8080
Vier Entscheidungen darin sind erklärungsbedürftig:
nginx-unprivileged statt nginx
Das Image lauscht per Default auf 8080 statt 80 und läuft als
Nicht-Root-User. Damit ist der Container ohne NET_BIND_SERVICE-Capability
lauffähig — Voraussetzung für restriktive Plattformen wie OpenShift.
NGINX_ENVSUBST_FILTER=ACTANOVA_
Der Entrypoint des nginx-Images rendert beim Start alle Dateien aus
/etc/nginx/templates/*.template per envsubst nach /etc/nginx/conf.d/.
Ohne Filter würde envsubst jede $…-Stelle ersetzen — und damit auch
nginx-eigene Variablen wie $host, $scheme oder $proxy_host, die dann leer
in der Config landen und die Konfiguration zerstören.
Der Filter beschränkt die Ersetzung auf Variablennamen mit dem Präfix
ACTANOVA_. nginx-Variablen bleiben dadurch unangetastet.
Der Filter ist ein Präfix, keine Liste. Eine neue Variable PORTAL_TITLE würde
nicht ersetzt, solange NGINX_ENVSUBST_FILTER=ACTANOVA_ gilt — die
Platzhalter bleiben dann wörtlich in der Config stehen.
chgrp -R 0 + chmod -R g=u
OpenShift (SCC restricted-v2) ignoriert das USER aus dem Image und startet
den Container mit einer zufälligen UID — die aber immer in der Gruppe GID 0
ist. Alles, was zur Laufzeit beschrieben wird, muss deshalb gruppenschreibbar
sein: die gerenderte Config unter /etc/nginx/conf.d, der Cache, die PID-Datei
in /tmp und der Web-Root. g=u spiegelt dazu die Owner-Rechte auf die Gruppe.
Ohne diesen Schritt startet der Container lokal einwandfrei und scheitert auf OpenShift beim Rendern der Vorlage.
ENV ACTANOVA_BASE_URL=""
Der leere Default ist Absicht: Er dokumentiert die Variable im Image, ohne ein
Ziel vorzugeben. Gesetzt wird sie zur Laufzeit — bleibt sie leer, rendert
envsubst ein proxy_pass /api/main/v1/; ohne Scheme und Host, also eine
ungültige Direktive. Das fällt beim Start auf und nicht erst beim ersten
API-Aufruf.
Die NGINX-Vorlage
server {
listen 8080;
server_name _;
# Uploads laufen als multipart/form-data -> großzügiges Limit
client_max_body_size 100m;
root /usr/share/nginx/html;
index index.html;
# authConfig.json nie cachen (das Sachbearbeiter-Cockpit lädt sie mit `cache: no-store`)
location = /authConfig.json {
add_header Cache-Control "no-store";
try_files $uri =404;
}
# API-Proxy: /api/cockpit/an/<x> -> ${ACTANOVA_BASE_URL}/api/main/v1/<x>
location /api/cockpit/an/ {
proxy_pass ${ACTANOVA_BASE_URL}/api/main/v1/;
proxy_http_version 1.1;
proxy_ssl_server_name on; # SNI an ActaNova senden
proxy_set_header Host $proxy_host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Forwarded-Host $host;
proxy_connect_timeout 30s;
proxy_read_timeout 120s;
proxy_send_timeout 120s;
}
# SPA: unbekannte Pfade auf index.html zurückfallen
location / {
try_files $uri $uri/ /index.html;
}
}
Die Datei heißt nginx/default.conf.template und ist die einzige Stelle mit
einem Platzhalter. $host, $scheme, $proxy_host und
$proxy_add_x_forwarded_for sind nginx-Variablen und überleben envsubst nur
wegen des Filters aus dem Abschnitt oben.
Zwei Details, die leicht untergehen:
- Der SPA-Fallback (
try_files … /index.html) ist Pflicht, sobald durouter="browser"verwendest — ohne ihn ergibt jeder Reload auf einer Unterseite einen 404. Beim Defaultrouter="hash"brauchst du ihn nicht, schaden tut er nicht. Siehe Routing. - Die
authConfig.jsondarf nicht gecacht werden. Sonst hängt ein Browser nach einem Umgebungswechsel an der altenauthorityund die Anmeldung scheitert ohne erkennbaren Grund.
ACTANOVA_BASE_URL richtig setzen
Erwartet wird Scheme + Host, ohne abschließenden Slash und ohne Pfad — der
Pfad /api/main/v1/ steht schon in der Vorlage:
ACTANOVA_BASE_URL=https://actanova.example.com # ✅
ACTANOVA_BASE_URL=https://actanova.example.com/ # ❌ doppelter Slash
ACTANOVA_BASE_URL=https://actanova.example.com/api/ # ❌ Pfad doppelt
ACTANOVA_BASE_URL=actanova.example.com # ❌ Scheme fehlt
Läuft die ActaNova-Instanz unter einem Unterpfad, gehört der mit hinein — im
Demo-Projekt etwa
https://actanova.dialogportal.ch/DialogPortalDemo/web, woraus dann
…/web/api/main/v1/ wird.
Mit docker compose
services:
cockpit:
build:
context: .
image: an-cockpit
container_name: an-cockpit
ports:
- "8080:8080"
environment:
ACTANOVA_BASE_URL: https://actanova.example.com
volumes:
- ./conf/authConfig.json:/usr/share/nginx/html/authConfig.json:ro
restart: unless-stopped
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8080/"]
interval: 30s
timeout: 5s
retries: 3
start_period: 10s
Der Mount ist der zweite Teil des „ein Image für alle Umgebungen"-Prinzips: Die
Datei liegt im Image bereits (sie kommt über publicDir: 'conf' in den Build),
wird hier aber überschrieben. Read-only (:ro) genügt, das Sachbearbeiter-Cockpit liest sie
nur.
Damit ergeben sich die beiden Stellschrauben pro Umgebung:
| Was | Womit |
|---|---|
| Ziel der API-Aufrufe | ACTANOVA_BASE_URL |
Keycloak-Realm, Client, checkPermission | gemountete authConfig.json |
Auf Kubernetes/OpenShift entspricht das einer ConfigMap für die
authConfig.json und einer Variable im Deployment — das Prinzip bleibt dasselbe.
Ohne Container
Der Container ist nur Verpackung. Gebraucht wird für jeden Webserver dasselbe:
npm run buildund den Inhalt vondist/statisch ausliefern/authConfig.jsonerreichbar machen — und nicht cachen/api/cockpit/an/auf<ActaNova>/api/main/v1/weiterleiten, inklusiveAuthorization-Header- bei
router="browser"einen SPA-Fallback aufindex.html
Fehlersuche
| Symptom | Wahrscheinliche Ursache |
|---|---|
Container startet nicht, nginx meldet einen Fehler in default.conf | ACTANOVA_BASE_URL nicht gesetzt — proxy_pass bleibt ohne Scheme und Host |
In der Config stehen leere Werte, wo $host stehen sollte | NGINX_ENVSUBST_FILTER fehlt — envsubst hat nginx-Variablen mitersetzt |
Alle API-Aufrufe ergeben 404 | Pfad in proxy_pass fehlt (/api/main/v1/) oder der abschließende Slash |
Aufrufe gehen an …com//api/main/v1/… | ACTANOVA_BASE_URL hat einen Slash am Ende |
| Fehlerbildschirm statt Anmeldung | authConfig.json nicht erreichbar, oder authority/client_id fehlen |
| Nach Umgebungswechsel die alte Keycloak-Instanz | authConfig.json wird gecacht — Cache-Control: no-store prüfen |
Reload auf einer Unterseite ergibt 404 | SPA-Fallback fehlt, siehe Routing |
| Auf OpenShift Schreibfehler beim Start | chgrp -R 0 / chmod -R g=u fehlen |
Weiter
- Sachbearbeiter-Cockpit aufsetzen —
authConfig.jsonund Proxy im Detail - Auth & Rechte — Realm-Rollen pro App
- Routing — warum der SPA-Fallback vom Router-Modus abhängt
