← Übersicht  ·  Skripte & Dateien  ·  Das ganze Projekt · 9 Services + Frontend + Infra

Bibliothek-Enterprise komplett auf Hetzner

Das vollstaendige LAB-WIP-MAVEN-Bibliothek-Enterprise auf Hetzner betreiben: alle 8 Spring-Services (in k8s ohne Eureka), das Angular-Frontend und die volle Infra (Postgres/7 DBs, Kafka, Keycloak, Prometheus/Grafana/Loki) – auf einem k3s-Cluster. Start als 1-Node-Cluster (CPX52), Ausbau auf 3 Nodes wenn HA (Hochverfügbarkeit) noetig. Baut auf dem k3s- und Microservices-Stack-Guide auf.

Stand: 29. August 2026 1–3 Nodes ~25–60 €/Monat

Platzhalter einsetzen – werden in allen Befehlen unten ersetzt, nur im Browser, nichts wird gesendet

Das Prinzip

In Kubernetes laufen 8 Services (kein Eureka – ADR-0007: k8s-DNS ersetzt es), das Angular-Frontend als nginx-Container und die Infra (Postgres/Kafka/Keycloak/Observability) als k8s-Workloads mit genau den Namen, die die vorhandenen Helm-values erwarten. Ein deploy.ps1 orchestriert die 8 Releases. Basis ist das generische Chart infra/helm/library-service + die values/-Dateien, die es schon gibt.

Internet bibliothek. / api. / auth.seb4u.com
k3s-Cluster (1–3 Nodes) Frontend · api-gateway · 6 Fachservices · config-server
Postgres · Kafka · Keycloak · Prometheus/Grafana/Loki
Blauer Streifen = Laptop (PowerShell / kubectl / helm) Roter Streifen = im Cluster (kubectl exec)
Setzt voraus

Den k3s-Guide (Cluster-Grundlagen) und den Microservices-Stack-Guide (Kafka-/Keycloak-Fallen, das Muster). Diese Anleitung fuellt jeden Befehl fuer dieses konkrete Projekt aus.

k8s vs. k3s

k3s ist Kubernetes – nur als schlanke Ein-Datei-Distribution. Dieselben Manifeste, dasselbe kubectl, dieselbe API (Application Programming Interface). Im Text meint k3s die konkrete Distribution, die hier auf dem Hetzner-Server laeuft (inkl. ihrer Extras wie local-path-Storage und der ServiceLB). k8s meint Kubernetes allgemein – die API, die Konzepte, alles was auf jeder Distribution gleich ist (networking.k8s.io/v1, „k8s-DNS-Discovery“ …). Und SPRING_PROFILES_ACTIVE=k8s ist einfach der Name, den das Projekt fuer sein Cluster-Profil gewaehlt hat – kein eigenes System.

01 1 Node oder Cluster?

1 Node3 Nodes
ServerCPX52 (16 vCPU / 32 GB)3× CPX42 (8 / 16) + Load Balancer
Reicht fueralles: 8 JVMs (~5 GB) + Kafka + Keycloak + Postgres + Observability (~6 GB) + Overheaddasselbe, plus Reserve & Ausfallsicherheit
HA (Node darf ausfallen)neinja (mit 3 Server-Nodes / embedded etcd)
Storagek3s local-path – einfachLonghorn oder Infra per nodeSelector pinnen
ca. €/Monat~26 + Backup~48 + Backup

Empfehlung: mit 1 Node starten. „Cluster bilden, wenn's sein muss“ = wenn du HA brauchst oder aus 32 GB (Gigabyte) rauswaechst – der Umstieg ist im Abschnitt Auf 3 Nodes wachsen, die Workloads bleiben identisch.

02 k3s-Cluster aufsetzen

Setzt Server-Basis voraus. Details im k3s-Guide. Hier die Kurzform – --disable traefik, weil wir ingress-nginx nehmen (die values-Dateien nutzen className: nginx).

Datei · cloud-init.yaml
#cloud-config
package_update: true
runcmd:
  - 'IP=$(curl -s http://169.254.169.254/hetzner/v1/metadata/public-ipv4); curl -sfL https://get.k3s.io | INSTALL_K3S_EXEC="server --disable traefik --tls-san $IP --write-kubeconfig-mode 644" sh -'
Laptop · PowerShell
hcloud firewall create --name lib-fw
$ip = (Invoke-RestMethod 'https://ipv4.icanhazip.com').Trim()
hcloud firewall add-rule lib-fw --direction in --protocol tcp --port 22   --source-ips "$ip/32"
hcloud firewall add-rule lib-fw --direction in --protocol tcp --port 6443 --source-ips "$ip/32"
hcloud firewall add-rule lib-fw --direction in --protocol tcp --port 80   --source-ips 0.0.0.0/0,::/0
hcloud firewall add-rule lib-fw --direction in --protocol tcp --port 443  --source-ips 0.0.0.0/0,::/0

hcloud server create --name lib-node1 --type cpx52 --image ubuntu-24.04 `
  --ssh-key laptop --firewall lib-fw --user-data-from-file cloud-init.yaml

$srv = (hcloud server ip lib-node1).Trim()
New-Item -ItemType Directory -Force $env:USERPROFILE\.kube | Out-Null
scp root@${srv}:/etc/rancher/k3s/k3s.yaml $env:USERPROFILE\.kube\biblio.yaml
(Get-Content $env:USERPROFILE\.kube\biblio.yaml) -replace '127\.0\.0\.1',$srv | Set-Content $env:USERPROFILE\.kube\biblio.yaml
$env:KUBECONFIG = "$env:USERPROFILE\.kube\biblio.yaml"
kubectl get nodes        # Ready?
Was die Schalter bedeuten

Drei Werkzeuge in einem Block – hcloud baut Firewall und Server, PowerShell holt die kubeconfig ab und biegt sie um:

Schaltergehört zubewirkt
--direction in
--source-ips "$ip/32"
firewall add-rule Hetzner-Firewalls filtern eingehend; ausgehend bleibt offen. /32 ist genau eine Adresse – die eigene, gerade von icanhazip.com geholte. SSH und die k3s-API sind damit nur vom eigenen Anschluss erreichbar. Wechselt die Adresse (DSL-Zwangstrennung, anderes Netz), muss die Regel nachgezogen werden.
--port 6443 firewall add-rule Der k3s-API-Server. Er gehört nicht ins offene Netz: wer ihn erreicht, redet mit dem Cluster-Admin-Endpunkt. Deshalb dieselbe /32-Quelle wie SSH, während 80 und 443 mit 0.0.0.0/0,::/0 für alle offen sind (IPv4 und IPv6 – beides muss dastehen).
--user-data-from-file cloud-init.yaml server create Übergibt die cloud-init-Datei von oben. Sie läuft einmal beim allerersten Start und installiert k3s – ein späterer Neustart führt sie nicht erneut aus. Ohne diesen Schalter entsteht ein leeres Ubuntu.
--firewall lib-fw / --ssh-key laptop server create Hängt Firewall und hinterlegten SSH-Schlüssel schon bei der Erstellung an. Wichtig ist die Reihenfolge: die Firewall greift damit ab der ersten Sekunde, statt dass der Server ein paar Minuten offen im Netz steht.
` am Zeilenende PowerShell Das Zeilenfortsetzungszeichen – PowerShells Gegenstück zum \ der Shell. Es muss das letzte Zeichen der Zeile sein; ein Leerzeichen dahinter bricht den Befehl still in zwei Teile.
-replace '127\.0\.0\.1',$srv PowerShell k3s schreibt in seine kubeconfig 127.0.0.1 – vom Server aus richtig, vom Laptop aus zeigt es ins Leere. Der Ausdruck ersetzt sie durch die öffentliche IP. Die Punkte sind maskiert, weil -replace mit regulären Ausdrücken arbeitet und ein nackter Punkt jedes Zeichen träfe.
--tls-san $IP
(in der cloud-init-Datei)
k3s Nimmt die öffentliche IP in das API-Zertifikat auf. Fehlt sie, scheitert jeder kubectl-Aufruf vom Laptop mit certificate is valid for 127.0.0.1, not <IP> – die Umschreibung oben allein reicht also nicht.
--write-kubeconfig-mode 644 k3s Macht /etc/rancher/k3s/k3s.yaml lesbar. Standard ist 600 für root – das scp aus der nächsten Zeile bliebe sonst an Permission denied hängen.
--disable traefik k3s k3s bringt Traefik als Ingress mit. Dieser Stack nutzt ingress-nginx (className: nginx in den values-Dateien); beide gleichzeitig würden sich um Port 80 und 443 streiten.

Lokal noch: winget install Kubernetes.kubectl Helm.Helm OpenJS.NodeJS (Node fuer den Frontend-Build), und podman/docker fuer die Image-Builds (oder die CI baut, siehe unten).

03 Cluster-Grundausstattung

Laptop · PowerShell (→ Cluster)
kubectl create namespace library

# Ingress-Controller (Typ LoadBalancer -> k3s ServiceLB bindet die Nodes-IP an 80/443)
helm repo add ingress-nginx https://kubernetes.github.io/ingress-nginx
helm install ingress-nginx ingress-nginx/ingress-nginx -n ingress-nginx --create-namespace `
  --set controller.service.type=LoadBalancer

# TLS
helm repo add jetstack https://charts.jetstack.io
helm install cert-manager jetstack/cert-manager -n cert-manager --create-namespace --set crds.enabled=true
Datei · cluster-issuer.yaml → kubectl apply -f
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: admin@seb4u.com
    privateKeySecretRef:
      name: letsencrypt-key
    solvers:
      - http01:
          ingress:
            class: nginx

04 Infra deployen (Bitnami-Charts)

fullnameOverride sorgt dafuer, dass die Services exakt postgres, redis, kafka, keycloak heissen – so wie die values-Dateien es erwarten.

Laptop · PowerShell (→ Cluster)
helm repo add bitnami https://charts.bitnami.com/bitnami
helm repo update

helm install postgres bitnami/postgresql -n library `
  --set fullnameOverride=postgres `
  --set auth.username=library --set auth.password='<db-pw>' --set auth.database=library `
  --set primary.persistence.size=10Gi

helm install redis bitnami/redis -n library `
  --set fullnameOverride=redis --set auth.enabled=false --set architecture=standalone

helm install kafka bitnami/kafka -n library `
  --set fullnameOverride=kafka --set listeners.client.protocol=PLAINTEXT `
  --set controller.persistence.size=8Gi

helm install keycloak bitnami/keycloak -n library `
  --set fullnameOverride=keycloak --set service.ports.http=8080 `
  --set auth.adminUser=admin --set auth.adminPassword='<kc-admin-pw>' `
  --set postgresql.enabled=false `
  --set externalDatabase.host=postgres --set externalDatabase.user=library `
  --set externalDatabase.password='<db-pw>' --set externalDatabase.database=keycloak_db `
  --set production=true --set proxy=edge `
  --set 'extraEnvVars[0].name=KC_HOSTNAME' --set 'extraEnvVars[0].value=auth.seb4u.com'

# Observability
helm repo add prometheus-community https://prometheus-community.github.io/helm-charts
helm install obs prometheus-community/kube-prometheus-stack -n library `
  --set grafana.adminPassword='<grafana-pw>'
helm install loki grafana/loki-stack -n library --set promtail.enabled=true
Was die Schalter bedeuten

Sechs Installationen. Die Werte unterscheiden sich bewusst von der lokalen CRC-/OKD-Variante – hier laufen die Dienste dauerhaft und öffentlich:

Schaltergilt fürbewirkt
helm repo add bitnami …
helm repo update
Vorbereitung Ein Chart-Repository ist ein Verzeichnis fertiger Installationspakete – wie eine Paketquelle für apt, nur für Kubernetes. add trägt die Adresse unter dem Kurznamen bitnami ein, update holt die Liste der verfügbaren Versionen. Erst danach versteht Helm die Schreibweise bitnami/postgresql. Beides ist einmalig pro Rechner nötig, nicht pro Cluster – und schadet nicht, wenn man es wiederholt.
-n library alle Aufrufe Der Namespace – die Schublade im Cluster, in der diese Anwendung liegt. Ohne die Angabe landet alles in default, wo es sich mit allem anderen mischt. Der Namensraum muss vorher existieren; Helm legt ihn nicht selbst an (außer mit --create-namespace).
helm install (ohne --install) hier bewusst Anders als in den anderen Anleitungen steht hier das schlichte helm install: es soll beim zweiten Lauf mit cannot re-use a name abbrechen. Diese Dienste laufen dauerhaft mit echten Daten – ein versehentliches erneutes Ausrollen wäre hier kein Komfort, sondern ein Risiko. Für Aktualisierungen ist helm upgrade der richtige Aufruf.
--set fullnameOverride=postgres usw. alle vier Infra-Charts Erzwingt den Service-Namen exakt so. Helm hängt sonst den Release-Namen davor (postgres-postgresql); die values-Dateien der Anwendung kennen aber nur postgres, redis, kafka und keycloak.
--set primary.persistence.size=10Gi
--set controller.persistence.size=8Gi
Postgres, Kafka Grösse des PersistentVolume. Anders als lokal bleibt persistence hier eingeschaltet – ein neu erstellter Pod darf keine Daten kosten. Die Grösse lässt sich bei k3s nachträglich nicht ohne Weiteres ändern, deshalb lieber gleich grosszuegig.
--set auth.enabled=false
--set architecture=standalone
Redis Kein Passwort, kein Replica-Set. Redis ist nur im Cluster-Netz erreichbar und dient als Cache – ein Replikat kostet einen zweiten Pod, ohne dass etwas davon abhängt.
--set listeners.client.protocol=PLAINTEXT Kafka Kein TLS zwischen den Diensten und Kafka. Vertretbar, solange Kafka keinen Service ausserhalb des Clusters bedient – es gibt hier bewusst keine Route darauf.
--set postgresql.enabled=false
--set externalDatabase.…
Keycloak Das Keycloak-Chart würde sonst eine zweite Postgres-Instanz mitinstallieren. Der erste Schalter schaltet die ab, die vier externalDatabase.* zeigen auf die vorhandene mit eigener Datenbank keycloak_db.
--set production=true
--set proxy=edge
Keycloak Der entscheidende Unterschied zur lokalen Variante: production=true verlangt HTTPS und einen festen Hostnamen. proxy=edge sagt Keycloak, dass TLS beim Ingress endet und es selbst unverschlüsselt bedient werden darf – ohne das wehrt es jede Anfrage als unsicher ab.
--set 'extraEnvVars[0].name=KC_HOSTNAME'
--set 'extraEnvVars[0].value=auth.…'
Keycloak Nagelt den Hostnamen fest, den Keycloak in Weiterleitungen und im iss-Claim der Tokens verwendet. Unter production=true ist er Pflicht. Die Anführungszeichen sind nötig, weil PowerShell die eckigen Klammern sonst selbst auswertet; der Index [0] adressiert den ersten Eintrag der Liste – Name und Wert müssen denselben Index tragen, sonst entstehen zwei halbe Einträge.
--set promtail.enabled=true loki-stack Startet den Log-Sammler auf jedem Node. Ohne ihn läuft Loki, bekommt aber nichts zu sehen – die Suche in Grafana bliebe dauerhaft leer.
Port-Abweichungen zur Compose-Welt

Bitnami-Kafka horcht auf kafka:9092 (nicht :29092 wie der Compose-interne Listener), Bitnami-Keycloak via service.ports.http=8080 wieder auf keycloak:8080. Das gleicht das Hetzner-Overlay in Schritt 7 aus.

05 Postgres-DBs & Keycloak-Realm

Laptop · PowerShell (→ Cluster)
# die 7 logischen DBs (Flyway in jedem Service legt danach die Schemas an, V1__init_*.sql):
kubectl exec -n library postgres-0 -- bash -c `
  'for db in catalog_db member_db lending_db reservation_db fine_db notification_db keycloak_db; do PGPASSWORD=<db-pw> psql -U library -h localhost -tc "SELECT 1 FROM pg_database WHERE datname=\"$db\"" | grep -q 1 || PGPASSWORD=<db-pw> psql -U library -h localhost -c "CREATE DATABASE $db"; done'
Laptop · PowerShell – Realm importieren
$kc = kubectl get pod -n library -l app.kubernetes.io/name=keycloak -o name
kubectl cp infra/keycloak/realm-library.json library/$($kc -replace 'pod/',''):/tmp/realm.json
kubectl exec -n library $kc -- kcadm.sh config credentials `
  --server http://localhost:8080 --realm master --user admin --password '<kc-admin-pw>'
kubectl exec -n library $kc -- kcadm.sh create realms -f /tmp/realm.json
Was hier passiert

Vier Zeilen, in denen dreimal derselbe Pod angesprochen wird – nur jedes Mal in einer anderen Schreibweise:

KonstruktZeilebewirkt
-l app.kubernetes.io/name=keycloak 1 Sucht über ein Label statt über den Namen. Der echte Pod-Name endet auf eine zufällige Zeichenfolge oder eine laufende Nummer und ändert sich bei jedem Neustart – das Label bleibt. Deshalb funktioniert dieselbe Zeile auch nach einem helm upgrade noch.
-o name 1 Gibt statt der Tabelle nur die Kennung aus, und zwar in der Form pod/keycloak-0. Praktisch für kubectl exec, das diese Schreibweise direkt versteht – unpraktisch für kubectl cp, das sie nicht mag. Daher die Zeile darunter.
$($kc -replace 'pod/','') 2 Schneidet das Präfix wieder ab. kubectl cp erwartet namespace/podname:pfad – mit pod/ darin entstünde library/pod/keycloak-0 und der Pod wäre nicht zu finden. Das $(…) drumherum ist nötig, weil PowerShell in einer Zeichenkette sonst nur einfache Variablen ersetzt, keine Ausdrücke.
kubectl cp <lokal> <ns>/<pod>:<pfad> 2 Kopiert die Realm-Datei in den Pod. Setzt tar im Zielcontainer voraus – bei ganz schlanken Images scheitert es daran mit einer Meldung, die den Grund nicht nennt. Unter Git Bash wandelt die Umgebung ausserdem :/tmp/… in einen Windows-Pfad um; dort hilft ein doppelter Schrägstrich (:://tmp/…). Unter PowerShell tritt das nicht auf.
kcadm.sh config credentials 3 Meldet sich an und legt das Ergebnis in einer Konfigurationsdatei ab, die der nächste Aufruf wiederfindet. --realm master ist der Verwaltungs-Realm, aus dem heraus andere Realms bearbeitet werden – nicht der, den wir gerade anlegen. --server http://localhost:8080 geht von innen gegen den eigenen Port und umgeht damit den Ingress samt Hostnamen-Frage.
kcadm.sh create realms -f /tmp/realm.json 4 Legt den Realm aus der kopierten Datei an. Dieser Aufruf ist nicht wiederholbar: existiert der Realm schon, endet er mit Conflict detected. Beim erneuten Aufsetzen entweder vorher delete realms/library oder die Meldung bewusst übergehen.
` am Zeilenende 3 Zeilenfortsetzung – PowerShells Gegenstück zum \ der Shell. Es muss das letzte Zeichen der Zeile sein; ein Leerzeichen dahinter zerlegt den Befehl still in zwei unvollständige Teile.

Kontrolle: curl -s https://auth.seb4u.com/realms/library/.well-known/openid-configuration – der issuer darin muss exakt die URL (Uniform Resource Locator) aus dem Overlay (Schritt 7) sein.

06 Images bauen & nach GHCR

Laptop · PowerShell (im Repo-Root)
echo $env:GH_PAT | podman login ghcr.io -u dein-gh-user --password-stdin
$sha = git rev-parse --short HEAD
$mods = 'catalog-service','member-service','lending-service','reservation-service',
        'fine-service','notification-service','config-server','api-gateway'
foreach ($m in $mods) {
  podman build -f library-platform/Dockerfile --build-arg MODULE=$m `
    -t "ghcr.io/dein-gh-user/$m:$sha" library-platform
  podman push "ghcr.io/dein-gh-user/$m:$sha"
}
Laptop · PowerShell (→ Cluster) – Pull-Secret
kubectl create secret docker-registry ghcr -n library `
  --docker-server=ghcr.io --docker-username=dein-gh-user --docker-password=$env:GH_PAT
kubectl patch serviceaccount default -n library `
  -p '{\"imagePullSecrets\":[{\"name\":\"ghcr\"}]}'

07 Hetzner-Helm-Overlay

Eine neue Overlay-Datei – wird als zweite -f hinter die service-spezifische Datei gehaengt. Setzt Prod-naehere Ressourcen und die Infra-Adressen/Issuer fuer diese Umgebung. Die DB-URLs stehen bereits richtig in den <service>-values.yaml (jdbc:postgresql://postgres:5432/…) – also kein per-Service-Overlay noetig.

Datei · infra/helm/values/env/hetzner-values.yaml
replicaCount: 2
autoscaling:
  enabled: true
resources:
  requests: { cpu: 300m, memory: 512Mi }
  limits:   { cpu: "1",  memory: 768Mi }
probes:
  liveness:  { initialDelaySeconds: 90 }
  readiness: { initialDelaySeconds: 45 }
env:
  # Bitnami-Kafka-Client-Port ist 9092 (nicht 29092 wie der Compose-interne Listener):
  SPRING_KAFKA_BOOTSTRAP_SERVERS: "kafka:9092"
  # iss-Claim (Browser-seitig) und Schluesselabruf zeigen auf die externe Keycloak-URL:
  SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_ISSUER_URI: "https://auth.seb4u.com/realms/library"
  SPRING_SECURITY_OAUTH2_RESOURCESERVER_JWT_JWK_SET_URI: "https://auth.seb4u.com/realms/library/protocol/openid-connect/certs"

08 Die 8 Services deployen

Datei · deploy.ps1 (im Repo-Root)
param([string]$Tag = (git rev-parse --short HEAD))
# 'Continue', weil kubectl/helm/git ihre Ausgabe nach stderr schreiben und
# PowerShell 5.1 das unter 'Stop' als Abbruch wertet. Fehler ueber $LASTEXITCODE.
$ErrorActionPreference = 'Continue'
$NS   = 'library'
$REPO = 'ghcr.io/dein-gh-user'
$DBPW = $env:LIB_DB_PW        # vorher: $env:LIB_DB_PW = '<db-pw>'

$svcs = 'config-server','api-gateway','catalog-service','member-service','lending-service',
        'reservation-service','fine-service','notification-service'

# DB-Secrets (config-server + api-gateway haben keine DB):
foreach ($s in $svcs) {
  if ($s -in 'config-server','api-gateway') { continue }
  kubectl create secret generic "$s-db-credentials" -n $NS `
    --from-literal=SPRING_DATASOURCE_USERNAME=library `
    --from-literal=SPRING_DATASOURCE_PASSWORD=$DBPW `
    --dry-run=client -o yaml | kubectl apply -f -
}

# Deploy (config-server zuerst):
foreach ($s in $svcs) {
  helm upgrade --install $s infra/helm/library-service -n $NS `
    -f infra/helm/values/$s-values.yaml `
    -f infra/helm/values/env/hetzner-values.yaml `
    --set image.repository="$REPO/$s" --set image.tag=$Tag
  if ($LASTEXITCODE -ne 0) { throw "helm upgrade $s fehlgeschlagen ($LASTEXITCODE)" }
  if ($s -eq 'config-server') { kubectl -n $NS rollout status deploy/config-server --timeout=180s }
}
kubectl get pods -n $NS
Die Hebel im Skript

Kurz, aber mit ein paar Kniffen, die den Unterschied zwischen „läuft einmal“ und „läuft jedes Mal“ ausmachen:

KonstruktStellebewirkt
$svcs = 'config-server','api-gateway',… Kopf Kommagetrennt ergibt in PowerShell ein Array – die Liste, über die beide Schleifen darunter laufen. Sie ist die einzige Stelle, an der ein neuer Dienst eingetragen werden muss; die Reihenfolge bestimmt zugleich die Reihenfolge des Ausrollens, weshalb config-server vorn steht.
foreach ($s in $svcs) { … } zweimal Zwei getrennte Durchläufe statt eines: erst alle Secrets, dann alle Deployments. Das ist Absicht – ein Pod, der startet, bevor sein Secret existiert, geht in eine Neustart-Schleife und braucht Minuten, um sich davon zu erholen.
param([string]$Tag = (git rev-parse --short HEAD)) Zeile 1 Der Vorgabewert ist ein Ausdruck, kein fester Text – er wird bei jedem Aufruf neu ausgewertet. Ohne Argument deployt das Skript also genau den gerade ausgecheckten Commit; mit -Tag v1.2.0 etwas anderes. Der Bezug zwischen laufendem Container und Quellstand entsteht allein an dieser Stelle.
$ErrorActionPreference = 'Continue' Kopf Bewusst nicht 'Stop': kubectl, helm und git schreiben auch normale Meldungen nach stderr, und PowerShell 5.1 wertet das unter Stop als abbrechenden Fehler. Deshalb prüft das Skript unten $LASTEXITCODE von Hand – das ist bei externen Programmen die einzige verlässliche Aussage über Erfolg.
$DBPW = $env:LIB_DB_PW Kopf Das Passwort steht in einer Umgebungsvariablen, nicht im Skript. Damit landet es weder im Repository noch in der PowerShell-Historie – und ein git diff vor dem Commit zeigt es nicht versehentlich mit.
--dry-run=client -o yaml | kubectl apply -f - Secrets Der wichtigste Kniff im Skript. kubectl create secret allein scheitert beim zweiten Lauf mit AlreadyExists. --dry-run=client lässt kubectl das Objekt nur berechnen, ohne den Cluster zu fragen, -o yaml gibt es aus, und apply -f - liest es von der Standardeingabe und legt es an oder aktualisiert es. Dieselben drei Schalter machen aus jedem create ein wiederholbares apply.
--from-literal=… Secrets Wert direkt aus der Kommandozeile statt aus einer Datei (--from-file). kubectl kodiert ihn selbst nach Base64 – wer das Passwort von Hand vorkodiert, bekommt ein doppelt kodiertes Secret und einen Dienst, der sich mit unverständlichem Fehler nicht anmelden kann.
if ($s -in 'config-server','api-gateway') { continue } Secrets Diese beiden haben keine Datenbank. Ein leeres Secret wäre harmlos, aber der continue hält die Liste ehrlich: was im Cluster steht, entspricht dem, was gebraucht wird.
-f …/$s-values.yaml
-f …/env/hetzner-values.yaml
helm upgrade Zwei Wertedateien in dieser Reihenfolge – die spätere gewinnt. Die erste beschreibt den Dienst (Port, Probes, Ressourcen), die zweite die Umgebung (Hostnamen, Replikate, Grenzen). Deshalb genügt für einen zweiten Cluster eine weitere Datei statt acht geänderter.
--set image.tag=$Tag helm upgrade --set gewinnt gegen beide Dateien. Der Tag ist der einzige Wert, der sich bei jedem Deployment ändert – er gehört deshalb nicht in eine versionierte Datei, sondern hierher.
helm upgrade --install helm upgrade Installiert beim ersten Lauf, aktualisiert bei jedem weiteren. helm install allein bricht beim zweiten Mal ab – dasselbe Muster wie bei den Secrets eine Zeile darueber.
throw "… fehlgeschlagen" nach jedem Dienst Bricht die Schleife wirklich ab. Ohne diese Zeile liefe sie unter ErrorActionPreference = 'Continue' stur weiter und meldete am Ende ein kubectl get pods, das gesund aussieht, während ein Dienst nie aktualisiert wurde.
rollout status deploy/config-server --timeout=180s Sonderfall Nur für den config-server: alle anderen Dienste holen beim Start ihre Konfiguration von ihm. Startet er nicht zuerst, gehen sie mit Connection refused in eine Neustart-Schleife und brauchen mehrere Minuten, um sich davon zu erholen. --timeout verhindert, dass das Skript bei einem echten Problem endlos wartet.
Laptop · PowerShell
$env:LIB_DB_PW = '<db-pw>'
.\scripts\deploy.ps1

discovery-server wird in k8s bewusst nicht deployt (ADR-0007). Das Chart aktiviert automatisch SPRING_PROFILES_ACTIVE=k8s, was den Eureka-Client abschaltet und k8s-DNS-Discovery nutzt.

Was ist Eureka – und warum hier aus?

Eureka (Netflix / Spring Cloud) ist ein Service-Discovery-Server – ein Telefonbuch fuer Microservices. Jeder Service meldet sich beim Start an („ich bin catalog-service, erreichbar unter 10.0.0.5:8080“), andere fragen Eureka nach der aktuellen Adresse. Loest das Problem, dass IPs (Internet-Protocol-Adressen)/Ports sich beim Neustart oder Skalieren aendern.

In Kubernetes ueberfluessig: jeder Service hat dort schon einen festen DNS-Namen (catalog-service.library.svc.cluster.local) und eine ClusterIP, die automatisch auf alle Pods verteilt. Eureka obendrauf waere ein zweites Telefonbuch fuer denselben Job – redundant, eine Fehlerquelle mehr. Darum: discovery-server gar nicht deployen, SPRING_PROFILES_ACTIVE=k8s schaltet den Client ab.

Lokal / bei docker compose (Stufe A/B, kein k8s-DNS) laeuft Eureka dagegen mit – deshalb existiert der discovery-server im Repo ueberhaupt.

09 Frontend bauen & deployen

Das Frontend hat API- und Keycloak-URL hartkodiert (src/app/core/api/api-config.ts, …/auth/auth.config.ts). Vor dem Build auf die Cloud-URLs setzen. redirectUri ist bereits dynamisch (window.location.origin).

Laptop · PowerShell (in library-frontend/)
(Get-Content src/app/core/api/api-config.ts) `
  -replace 'http://localhost:8080','https://api.seb4u.com' | Set-Content src/app/core/api/api-config.ts
(Get-Content src/app/core/auth/auth.config.ts) `
  -replace 'http://localhost:8180','https://auth.seb4u.com' | Set-Content src/app/core/auth/auth.config.ts

npm ci
npm run build        # -> dist/library-frontend/browser/
Datei · library-frontend/Dockerfile + nginx.conf
# Dockerfile
FROM nginx:1.27-alpine
COPY dist/library-frontend/browser/ /usr/share/nginx/html/
COPY nginx.conf /etc/nginx/conf.d/default.conf

# nginx.conf
server {
  listen 80;
  root /usr/share/nginx/html;
  location / { try_files $uri $uri/ /index.html; }   # SPA-Fallback
}
Laptop · PowerShell
podman build -t "ghcr.io/dein-gh-user/library-frontend:$sha" library-frontend
podman push  "ghcr.io/dein-gh-user/library-frontend:$sha"

kubectl create deployment frontend -n library --image="ghcr.io/dein-gh-user/library-frontend:$sha"
kubectl expose deployment frontend -n library --port=80

10 Keycloak-Client & CORS

  • Im Keycloak-Admin (https://auth.seb4u.com, Realm library, Client library-frontend): Valid redirect URIs (Uniform Resource Identifiers) = https://bibliothek.seb4u.com/*, Web origins = https://bibliothek.seb4u.com.
  • CORS am api-gateway: pruefen, ob api-gateway/application.yml im k8s-Profil die Frontend-Origin erlaubt. Falls localhost-fest – per Env nachziehen (im api-gateway-Overlay): SPRING_CLOUD_GATEWAY_GLOBALCORS_CORSCONFIGURATIONS_[/**]_ALLOWEDORIGINS: "https://bibliothek.seb4u.com".

11 DNS, TLS, End-to-End pruefen

Laptop · PowerShell
kubectl get svc -n ingress-nginx ingress-nginx-controller -o jsonpath='{.status.loadBalancer.ingress[0].ip}'

Drei A-Records auf diese IP (Internet Protocol): bibliothek., api., auth.seb4u.com.

Domain schon bei Cloudflare

Liegt seb4u.com als Full-Zone bei Cloudflare (DNS + Registrar – siehe Domain zu Cloudflare umziehen), legst du die drei A-Records (bibliothek, api, auth) direkt in der bestehenden Zone an – „DNS only“ (grau), damit ingress-nginx + cert-manager das Let’s-Encrypt-Zertifikat holen können. Kein Konto, keine Nameserver. Proxy (orange) optional erst nach dem ersten erfolgreichen Zertifikat, dann SSL (Secure Sockets Layer)/TLS-Modus „Full (strict)“.

Datei · ingress.yaml → kubectl apply -f -n library
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: biblio
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt
spec:
  ingressClassName: nginx
  tls:
    - hosts: [ bibliothek.seb4u.com, api.seb4u.com, auth.seb4u.com ]
      secretName: biblio-tls
  rules:
    - host: bibliothek.seb4u.com
      http: { paths: [ { path: /, pathType: Prefix, backend: { service: { name: frontend,    port: { number: 80 } } } } ] }
    - host: api.seb4u.com
      http: { paths: [ { path: /, pathType: Prefix, backend: { service: { name: api-gateway, port: { number: 8080 } } } } ] }
    - host: auth.seb4u.com
      http: { paths: [ { path: /, pathType: Prefix, backend: { service: { name: keycloak,    port: { number: 8080 } } } } ] }
End-to-End

https://bibliothek.seb4u.com oeffnen → Login erika.mustermann / member123 → Katalog laedt → eine Ausleihe → in Grafana die Metrik sehen. Debug: kubectl get pods,ingress,certificate -n library, kubectl describe certificate -n library, kubectl logs.

12 Backups & Betrieb

  • Postgres: ein CronJob, der pg_dumpall (oder je DB pg_dump) macht und per rclone zu einer Hetzner Storage Box oder Backblaze B2 schiebt – ausserhalb des Clusters (Grundausstattung).
  • Keycloak-Realm: regelmaessig exportieren (kcadm.sh get realms/library).
  • k3s-State: /var/lib/rancher/k3s/server/db/ (SQLite) bzw. etcd-Snapshots sichern.
  • Skalieren: autoscaling.enabled: true ist im Overlay gesetzt – kubectl get hpa -n library, kubectl top pods beobachten.
  • Update: neuer $sha.\scripts\deploy.ps1 -Tag <sha>. Rollback eines Service: helm rollback catalog-service -n library.
  • Wegwerf-Session: Node-Snapshot (enthaelt Cluster + Images) oder alle lib-*-Server loeschen und mit deploy.ps1 in ~20 Min neu aufbauen.

+ CI/CD (GitOps)

Der container-build-Job der CI baut aktuell nur. Push + Deploy anhaengen:

Datei · .github/workflows/ci.yml (neuer Job)
  push-and-deploy:
    needs: [container-build]
    runs-on: ubuntu-latest
    if: github.ref == 'refs/heads/main'
    steps:
      - uses: actions/checkout@v4
      - run: echo "${{ secrets.GHCR_PAT }}" | podman login ghcr.io -u ${{ github.actor }} --password-stdin
      - run: |
          for m in catalog-service member-service lending-service reservation-service \
                   fine-service notification-service config-server api-gateway; do
            podman build -f library-platform/Dockerfile --build-arg MODULE=$m \
              -t ghcr.io/${{ github.repository_owner }}/$m:${{ github.sha }} library-platform
            podman push ghcr.io/${{ github.repository_owner }}/$m:${{ github.sha }}
          done
      - run: echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > kc.yaml
      - env: { KUBECONFIG: kc.yaml }
        run: |
          for s in config-server api-gateway catalog-service member-service lending-service \
                   reservation-service fine-service notification-service; do
            helm upgrade --install "$s" infra/helm/library-service -n library \
              -f infra/helm/values/$s-values.yaml -f infra/helm/values/env/hetzner-values.yaml \
              --set image.repository=ghcr.io/${{ github.repository_owner }}/$s \
              --set image.tag=${{ github.sha }}
          done
Was hier passiert

Zwei Ebenen in einer Datei: GitHub wertet zuerst alles in ${{ }} aus und schreibt das Ergebnis in ein Shell-Skript – erst dann läuft die Shell. Die meisten Verständnisfragen hier kommen daher.

KonstruktStellebewirkt
needs: [container-build] Kopf Dieser Job startet erst, wenn container-build erfolgreich war. Ohne die Zeile liefen beide gleichzeitig – GitHub führt Jobs standardmässig parallel aus, Schritte innerhalb eines Jobs dagegen nacheinander.
if: github.ref == 'refs/heads/main' Kopf Der Job wird nur auf dem Hauptzweig ausgeführt; auf allen anderen übersprungen (und gilt trotzdem als erfolgreich). So bauen und testen Feature-Branches, ohne auszurollen. In if: stehen die ${{ }} ausnahmsweise nicht – dort wird ohnehin ausgewertet.
runs-on: ubuntu-latest Kopf Ein frischer, leerer Rechner für jeden Lauf. Nichts überlebt zwischen zwei Läufen – deshalb das checkout als ersten Schritt, und deshalb muss die kubeconfig unten jedes Mal neu entstehen.
uses: actions/checkout@v4 Schritt 1 Holt das Repository auf den Runner. Klingt selbstverständlich, ist es aber nicht: ohne diesen Schritt ist das Arbeitsverzeichnis leer, und podman build fände weder Dockerfile noch Quellcode. Das @v4 ist die Version der Action.
${{ github.sha }} gegen $m überall Die wichtigste Unterscheidung in dieser Datei. ${{ }} wertet GitHub aus, bevor die Shell startet – im fertigen Skript steht dann der nackte Wert. $m dagegen ist eine Variable der Shell und existiert nur innerhalb der Schleife. Deshalb funktioniert die Mischung in einer Zeile: der Commit-Hash ist da schon eingesetzt, der Modulname wird erst beim Durchlauf ersetzt. Umgekehrt gilt: eine Schleifenvariable in ${{ }} zu schreiben, kann nicht gehen – GitHub kennt sie nicht.
${{ secrets.GHCR_PAT }} Anmeldung Ein in den Repository-Einstellungen hinterlegter Wert. GitHub setzt ihn ein und ersetzt ihn zugleich in der Protokollausgabe durch Sterne – ein versehentliches echo verrät ihn also nicht. Das gilt nur für Werte aus secrets, nicht für selbst zusammengebaute.
| podman login --password-stdin Anmeldung Das Token kommt über die Standardeingabe statt als Argument. Argumente sind in der Prozessliste sichtbar – die Standardeingabe nicht. Aus demselben Grund warnt docker login -p.
--build-arg MODULE=$m
library-platform am Ende
Build Ein einziges Dockerfile baut alle acht Dienste; welcher, entscheidet das Build-Argument. Das library-platform ganz am Ende ist kein vergessener Schalter, sondern der Build-Kontext: das Verzeichnis, das an den Bauvorgang übergeben wird. -f zeigt auf das Dockerfile darin.
run: | mehrzeilig Der senkrechte Strich ist YAML für „alles Folgende wörtlich, mit Zeilenumbrüchen“. Nur so lässt sich eine Schleife schreiben. Der \ am Zeilenende bleibt trotzdem nötig, weil die Shell darin ganz normal Zeile für Zeile liest.
echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > kc.yaml Zugang Die kubeconfig ist mehrzeilig und enthält Zertifikate – als Klartext-Secret wäre sie unhandlich, deshalb liegt sie Base64-kodiert dort und wird hier zurückverwandelt. -d ist entschlüsseln (auf macOS heisst es -D). Base64 ist keine Verschlüsselung – die Datei liegt danach im Klartext auf dem Runner, und wer dort einen beliebigen Schritt einschleusen kann, hat vollen Cluster-Zugang. Deshalb gehört ein solcher Zugang zu einem Dienstkonto mit eng gesetzten Rechten, nicht zum Cluster-Admin.
env: { KUBECONFIG: kc.yaml } Deploy-Schritt Setzt die Variable nur für diesen Schritt. kubectl und helm lesen sie von sich aus. Ein relativer Pfad genügt, weil jeder Schritt im selben Arbeitsverzeichnis startet.
-f …/$s-values.yaml
-f …/env/hetzner-values.yaml
helm Zwei Wertedateien in dieser Reihenfolge – die spätere gewinnt. Die erste beschreibt den Dienst, die zweite die Umgebung. Deshalb genügt für einen zweiten Cluster eine weitere Datei statt acht geänderter.
helm upgrade --install helm Installiert beim ersten Lauf, aktualisiert bei jedem weiteren – Voraussetzung dafür, dass die Pipeline bei jedem Push erneut laufen darf. helm install allein bräche beim zweiten Mal ab.

Secrets: GHCR_PAT, KUBECONFIG_B64. GitOps-Alternative: Argo CD im Cluster, das die 8 Releases aus einem Git-Repo synct – git push = Deploy. Jenkins (aus shared-infra/) auf einem eigenen kleinen Server oder als Cluster-Workload.

Vollständig durchgespielt: Jenkins → OKD (OpenShift Kubernetes Distribution)

Statt k3s + deploy.ps1: dasselbe Projekt über die projekteigene vierstufige Jenkins-Pipeline auf den OKD-Single-Node – mit OKD-interner Registry, ServiceAccount-Rechten und allen 17 Stolpersteinen (Bitnami-Paywall, verschachteltes Podman, config-server-Profil, Probe-Timing …) dokumentiert.

+ Auf 3 Nodes wachsen

Wenn HA noetig ist oder 32 GB nicht mehr reichen. Die Workloads bleiben unveraendert.

  • Privates Netz: hcloud network create, alle Nodes hineinhaengen – Node-zu-Node-Traffic nicht uebers Internet.
  • Storage zuerst: local-path klebt Daten an einen Node. Vor dem Multi-Node Longhorn installieren und als Default-StorageClass – oder die stateful Infra (Postgres, Kafka) per nodeSelector auf lib-node1 pinnen.
  • Weitere Nodes: als k3s-Agents joinen (K3S_URL/K3S_TOKEN, siehe k3s-Guide „Mehrere Nodes“).
  • HA-Control-Plane: 3 Server-Nodes mit --cluster-init (embedded etcd) statt 1 Server + 2 Agents – dann darf ein Node ausfallen, ohne dass die API weg ist. Davor ein Hetzner Load Balancer auf Port 6443 der 3 Server.

+ Kosten

Preise sind Richtwerte. Hetzner hat 2026 zweimal erhöht (zuletzt 15. Juni: CX/CAX +30–40 %, CPX/CCX über 100 %). Aktuelle Server-Preise, alle Nebenposten, ein Rechner und Beispielrechnungen stehen zentral in Kosten & Budget.

SetupServer+ Extrasca. €/Tagca. €/Monat
1 NodeCPX42 (8/16) + IPv4 (Internet Protocol Version 4), oder CAX31 ARM (Advanced RISC Machines) ~21 falls freiDomain + Storage Box (~4)~2,3~70
1 Node, Infra managedCPX42 (8/16)+ Neon Postgres + Grafana Cloud (Free)~2,3~70
3 Nodes HACPX42 (8/16)+ Load Balancer + IPv4×3 + Backup~7,3~221

Managed-Infra (Neon Postgres, Grafana Cloud, CloudAMQP, Confluent) verschiebt RAM-Last aus dem Cluster – damit reicht oft ein CPX42 als 1 Node. Details im Microservices-Stack-Guide, Abschnitt Managed-Infra.