← Übersicht  ·  Skripte & Dateien  ·  Von lokal in die Cloud · Spring-Microservices + Infra

Microservices-Stack auf Hetzner

Einen lokalen podman compose-/Helm-Microservices-Stack schrittweise auf Hetzner bringen – am Beispiel eines Projekts wie Bibliothek-Enterprise (9 Spring-Services + Postgres/Kafka/Keycloak/Observability). In drei Ausbaustufen: erst nur die Infra (Code bleibt in der IDE), dann der ganze Stack, dann als Helm-Deployment auf k3s. Mit den Fallen, die dabei zuverlässig zuschlagen.

Stand: 1. September 2026 3 Ausbaustufen setzt die anderen cloud-host-Guides voraus

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

Das Prinzip

Dein Projekt bringt drei Deploy-Ziele mit: podman compose (infra/docker-compose.yml), das Helm-Chart (infra/helm/library-service + values/) und kind (Wegwerf-k8s der CI). Die Cloud-Guides sind der Ort, an dem Ziel 1 und 2 richtig laufen: kind → k3s auf Hetzner, lokales Compose → Compose auf einem Server. Testcontainers bleibt lokal bzw. in CI (Continuous Integration) – das ist Testzeit, wird nicht deployt.

Dein Laptop IDE (mvn spring-boot:run) · kubectl · helm
Hetzner A: nur Infra · B: + alle Services
C: k3s + Helm-Deployment
Blauer Streifen = Laptop (PowerShell / IDE) Roter Streifen = Server (bash, per ssh)
Setzt voraus

Einen der Guides schon durchgespielt: container-host (Podman) für Stufe A/B, k3s für Stufe C. Und Tailscale aus der Grundausstattung-Checkliste.

01 Welche Ausbaustufe?

Was „asynchron“ konkret bedeutet – und warum ein gelesenes Ereignis nicht verschwindet.

Zeichnung aus Diagramme: Hetzner & CRC (29).

lending-service Producer send – und weiter TCP 9092 · PLAINTEXT wartet nicht auf den Leser Topic „library.lending.loan-created“ Partition 0 – hier die einzige, so konfiguriert loan #41Offset 0 loan #42Offset 1 loan #43Offset 2 loan #44Offset 3 nächstes wächst nur hinten Die Reihenfolge gilt innerhalb einer Partition – sonst nirgends. Mehr Partitionen bringen mehr Durchsatz und kosten genau diese Garantie; der Schlüssel des Ereignisses entscheidet, in welche es fällt. Gruppe „notification-service“ gelesen bis Offset 3 committed die Position selbst, nach dem Verarbeiten Absturz davor → liest ab 3 erneut: mindestens einmal, nicht genau einmal. Der Empfänger muss das aushalten. Gruppe „fine-service“ gelesen bis Offset 1 völlig unabhängig von der anderen Gruppe Genau das unterscheidet Kafka von einer Warteschlange: Lesen entfernt nichts. fetch · TCP 9092 · jede Gruppe holt selbst ab controller.replicaCount=1 Ein KRaft-Controller. Kein ZooKeeper mehr nötig – aber auch keine Ausfallsicherheit. Was daraus folgt 1. Der Producer ist sofort fertig. Eine langsame oder abgestuerzte Benachrichtigung verzögert keine einzige Ausleihe – anders als beim HTTP-Aufruf an catalog-service, wo lending wartet und mit hängt. 2. Ereignisse bleiben liegen (Vorgabe: 7 Tage). Ein neu gestarteter Dienst kann von vorn lesen. Deshalb ist persistence hier nicht nur Komfort: mit emptyDir ist nach einem Pod-Wechsel der ganze Verlauf weg, und keiner merkt es. 3. Mindestens einmal, nicht genau einmal. Wer zweimal dieselbe Mahnung verschicken darf, hat kein Problem; wer zweimal abbucht, schon. 4. Fällt der eine Controller aus, steht der gesamte Ereignisfluss. Für einen Lern-Cluster in Ordnung – in Produktion wären es drei.
Kafka ist ein Protokoll, keine Warteschlange. Ereignisse werden hinten angehängt und bleiben liegen; jede Lesergruppe merkt sich nur ihre eigene Position. Daraus folgt beides: der Sender wird nie ausgebremst – und ein Fehler beim Empfänger fällt niemandem auf, solange keiner die Leseposition beobachtet.
StufeWas läuft in der CloudServerWofür
Anur Infra (Postgres, Kafka, Keycloak, Observability). Die 9 Spring-Services laufen in der IDE.CPX22–CPX32 (~4 GB Infra)Bester Dev-Loop: Debugger + Hot-Reload lokal, Laptop von 20 Containern auf 0.
Bder ganze compose-Stack inkl. aller Services, Traefik davorCPX42 (8 vCPU / 16 GB)Teilbare Demo / Staging unter echten URLs (Uniform Resource Locators).
Ck3s-Cluster, Infra als Workloads, Services per helm upgrade --installCPX42/CPX52 oder 2–3 Nodes + LB (Load Balancer)Das „Echte“: ersetzt kind, HPA (HorizontalPodAutoscaler) / Probes / Ingress real, CI kann deployen.

Reihenfolge: A → B → C. Jede Stufe baut auf der vorigen auf.

Kein Parallelbetrieb: das sind nicht drei Server nebeneinander, sondern ein Server, der mitwaechst. A → B heißt denselben Server größer neu aufsetzen. Bei B → C wechselst du auf den k3s-Guide. Mehr als einen Server hast du frühestens bei Stufe C – und auch nur, wenn du dort die Cluster-Variante (2–3 Nodes + LB) statt eines starken Single-Node nimmst. Der Load Balancer ist dabei kein Server (managed, optional, ~5 €/Mo) – was zählt als Server und was nur als Rechnungsposten: siehe Kosten & Betrieb.

Compose oder Kubernetes – ist das eine Alternative zum anderen?

Teilweise. Beide fahren einen Multi-Container-Stack, aber in verschiedener Größe: Compose auf einem Host mit Befehlen (up/down), Kubernetes über einen Cluster mit Soll-Zustand, den es selbst abgleicht. Stufe A/B sind Compose, Stufe C ist Kubernetes.

Compose (Docker/Podman)Kubernetes (k3s)
Reichweiteein HostCluster aus mehreren Nodes
BedienungBefehle: up / down / pullSoll-Zustand beschreiben, Cluster gleicht ab
Neustart nach Crashrestart: always – nur auf demselben HostSelf-Healing, auch bei Node-Ausfall (Pod woanders)
Skalieren--scale web=3, ohne echtes Load-BalancingRepliken + Service-LB + HPA (automatisch)
Rolling Update / Rollback– (kurz weg beim Neustart)health-gated, kubectl rollout undo
Ingress / LBselbst davorstellen (Traefik/Caddy)eingebaut (Service, Ingress)
Secrets, RBAC (Role-Based Access Control), Netzwerk-Policiesrudimentaereingebaut
Operator-/CRD-Oekosystem (cert-manager …)ja
Bewegliche Teile / Lernkurvewenigviel
  • Compose, wenn: 1 Server, überschaubare Zahl Dienste, „reboot → restart: always reicht“. Weniger, was kaputtgehen kann. Das ist auch der dienste-stack.ps1-Workflow.
  • Kubernetes, wenn: „ein Node darf ausfallen“, Auto-Skalierung, saubere Rolling Deploys, viele Services/Teams, CI soll deployen.

Dazwischen: Docker Swarm (Compose-Syntax, mehrere Nodes, viel einfacher als k8s – aber praktisch abgekündigt) und podman kube play (fährt Kubernetes-YAML auf einem Host ohne Cluster – gut, um dieselben Manifeste lokal und im Cluster zu nutzen). Details zu k8s selbst: Abschnitt „Was Kubernetes übernimmt“ im k3s-Guide.

02 Server + Tailscale

Setzt den einmaligen Aufbau voraus – Konto, API-Token, hcloud, SSH-Key, Firewall-Prinzip: Server-Basis. Danach der Server selbst nach Container-Host (Stufe A/B) bzw. k3s (Stufe C).

Dann Tailscale drauf – danach ist der Server von außen unsichtbar und du brauchst keine Firewall-IP-Freigaben für Postgres/Kafka…

Server · bash
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
tailscale status        # Hostname merken, z.B. stack-host
Laptop · PowerShell
# Firewall: SSH-Regel loeschen (Zugang laeuft ueber Tailscale), nur 80/443 fuer Traefik/Ingress
hcloud firewall delete-rule stack-fw --direction in --protocol tcp --port 22 --source-ips "$ip/32" 2>$null
# oder einfach in der Console die Port-22-Regel entfernen

Der Server heißt im Tailnet stack-host.dein-tailnet.ts.net – diese Adresse taucht ab jetzt überall auf.

03 infra/ auf den Server bringen

Die Compose-Datei mountet Dateien vom Host (postgres/init-multiple-dbs.sh, keycloak/realm-library.json, prometheus/prometheus.yml, Grafana-Provisioning). Die lösen auf dem Server-Dateisystem – der infra/-Baum muss also dort liegen.

Laptop · PowerShell (im Repo-Root)
# Variante 1: rsync (WSL / Git-Bash)
rsync -az --delete infra/ root@stack-host.dein-tailnet.ts.net:/opt/stack/infra/

# Variante 2: auf dem Server klonen und pullen
ssh root@stack-host.dein-tailnet.ts.net `
  "mkdir -p /opt/stack && cd /opt/stack && git clone <repo-url> . || git -C /opt/stack pull"
Nicht die Repo-Klartextcreds als Prod-Secrets nehmen

Die Werte im Repo (library/library, admin/admin, die 3 Testnutzer) sind „nur lokal“. Für Stufe A/B im Tailnet (nichts öffentlich) tolerierbar – ab dem Moment, wo Traefik/Ingress etwas öffentlich macht: echte Secrets, siehe Schritt 12.

04 Stufe A – nur Infra in der Cloud

Server · bash
cd /opt/stack/infra
podman compose up -d postgres pgadmin redis kafka kafka-ui rabbitmq keycloak \
  config-server prometheus grafana zipkin loki promtail
podman compose ps

Die 9 Spring-Services nicht starten – die kommen aus der IDE. config-server mit hoch, weil die Services ihn beim Start ziehen. discovery-server nur wenn dein Setup Eureka nutzt (im Beispielprojekt nur im Compose-Profil).

05 Stufe A – lokale App dagegen laufen lassen

Ein Spring-Profil cloud, das auf die Tailnet-Adresse zeigt statt localhost.

Datei · <service>/src/main/resources/application-cloud.yml (Beispiel catalog-service)
spring:
  datasource:
    url: jdbc:postgresql://stack-host.dein-tailnet.ts.net:5432/catalog_db
  data:
    redis:
      host: stack-host.dein-tailnet.ts.net
  kafka:
    bootstrap-servers: stack-host.dein-tailnet.ts.net:9092
  config:
    import: optional:configserver:http://stack-host.dein-tailnet.ts.net:8888
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://stack-host.dein-tailnet.ts.net:8180/realms/library
Laptop · IDE / PowerShell
mvn -pl catalog-service spring-boot:run -Dspring-boot.run.profiles=cloud

Über Tailscale sind die Ports erreichbar, ohne dass du in der Hetzner-Firewall irgendetwas öffnest – Tailscale ist ein eigenes, verschlüsseltes Netz.

06 Falle 1 – Kafka advertised listeners

Ein Kafka-Client verbindet sich zu irgendeiner Adresse, bekommt dann per Metadata-Antwort die advertised Adresse genannt – und verbindet sich dorthin neu. Die Compose-Datei announced PLAINTEXT_HOST als localhost:9092 – dein IDE-Prozess auf dem Laptop landet also bei sich selbst.

Datei · infra/docker-compose.override.yml (compose liest sie automatisch dazu)
services:
  kafka:
    environment:
      KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://stack-host.dein-tailnet.ts.net:9092,KIND_HOST://host.containers.internal:9094
Server · bash
cd /opt/stack/infra
podman compose up -d kafka        # uebernimmt die override-Datei

Stufe C (k3s): alle Clients sind im Cluster → der Kafka-Pod announced den k8s-Service-Namen kafka:29092, das passt. PLAINTEXT_HOST brauchst du nur, wenn Clients außerhalb des Clusters zugreifen.

07 Falle 2 – Keycloak-Issuer

Jeder Token hat einen iss-Claim. Der Resource-Server prüft: kommt der Token von der erwarteten Issuer-URL? Muss exakt stimmen (Schema, Host, Port, Pfad). Keycloak muss also unter einer stabilen URL (Uniform Resource Locator) erreichbar sein und diese als Issuer ausgeben.

Datei · infra/docker-compose.override.yml (Ergänzung)
  keycloak:
    environment:
      KC_HOSTNAME: stack-host.dein-tailnet.ts.net
      KC_HOSTNAME_PORT: "8180"
      KC_HOSTNAME_STRICT: "false"
      KC_HTTP_ENABLED: "true"
Server · bash – prüfen
podman compose up -d keycloak
curl -s http://stack-host.dein-tailnet.ts.net:8180/realms/library/.well-known/openid-configuration | grep -o '"issuer":"[^"]*"'
# muss GENAU der issuer-uri aus application-cloud.yml entsprechen

Stufe C: Keycloak als k8s-Service keycloak:8080 für die Service-zu-Service-Validierung – aber die Browser-/Frontend-Flows brauchen eine von außen erreichbare Keycloak-URL: Keycloak zusätzlich per Ingress unter auth.lab.seb4u.com + KC_HOSTNAME darauf.

08 Stufe B – ganzer Stack + Traefik

Server · bash
cd /opt/stack/infra
podman compose up -d        # jetzt ALLES, inkl. der 9 Services (Erst-Build dauert Minuten)
podman stats                # RAM im Blick behalten
  • RAM (Random Access Memory): CPX42 (16 GB) realistisch – Kafka + Keycloak + 8 JVMs (Java Virtual Machines). resources später tunen.
  • Öffentlich: die enterprise-infrastructure-Traefik dazustarten. Statt *.localhost eine echte Wildcard-DNS: *.lab.seb4u.com<IP> (ein A-Record).
  • Die Traefik-Regeln von Host(`pgadmin.library.localhost`) auf Host(`pgadmin.lab.seb4u.com`) umziehen (oder eine Env-Variable dafür einführen).
  • Traefik mit Let’s Encrypt – für eine Wildcard braucht es die DNS-Challenge (API-Token deines DNS-Anbieters).
  • Firewall: 80+443 öffentlich (ACME + Zugriff), 22 zu (Tailscale).
Ab jetzt öffentlich = ab jetzt ernst

Echte Secrets (Schritt 12), Keycloak-Issuer auf auth.lab.seb4u.com, und mindestens Basic-Auth vor Grafana / pgAdmin / Kafka-UI (Traefik-Middleware). Die 3 Keycloak-Testnutzer-Passwörter ändern.

09 Stufe C – k3s + Infra im Cluster

k3s-Guide → Cluster. Das ersetzt infra/kind/.

Die Infra muss als k8s-Workloads mit denselben Namen laufen, die die Helm-values erwarten (postgres, redis, kafka, keycloak, config-server). Drei Wege:

Laptop · PowerShell (→ Cluster) – Weg 1: Bitnami-Charts
kubectl create namespace library
helm install postgres oci://registry-1.docker.io/bitnamicharts/postgresql -n library `
  --set fullnameOverride=postgres --set auth.username=library --set auth.password=library --set auth.database=library
helm install redis oci://registry-1.docker.io/bitnamicharts/redis -n library `
  --set fullnameOverride=redis --set auth.enabled=false
helm install kafka oci://registry-1.docker.io/bitnamicharts/kafka -n library `
  --set fullnameOverride=kafka --set listeners.client.protocol=PLAINTEXT
helm install keycloak oci://registry-1.docker.io/bitnamicharts/keycloak -n library `
  --set fullnameOverride=keycloak --set auth.adminUser=admin --set auth.adminPassword='<pw>'
Was die Schalter bedeuten

Auffällig ist, was fehlt: kein helm repo add. Diese Anleitung holt die Charts direkt aus einer OCI-Registry:

Schaltergilt fürbewirkt
kubectl create namespace library zuerst Der Namespace ist die Schublade im Cluster, in der alles Folgende liegt. Helm legt ihn nicht selbst an (außer mit --create-namespace), und ohne ihn scheitert die erste Installation. Ein zweiter Aufruf endet mit AlreadyExists – harmlos, aber ein Grund, warum dieser Block nicht unbesehen wiederholt werden sollte.
oci://registry-1.docker.io/bitnamicharts/… alle vier Ein OCI-Chart: Helm lädt es wie ein Container-Image aus einer Registry, statt aus einem klassischen Chart-Repository. Deshalb gibt es hier kein helm repo add und kein helm repo update – die vollständige Adresse steht direkt im Aufruf. Der Vorteil ist ein Schritt weniger und immer die aktuelle Fassung; der Nachteil, dass helm search repo hier nichts findet und man die Version mit --version ausdrücklich festnageln muss, wenn Wiederholbarkeit gewünscht ist.
helm install (ohne --install) alle vier Installiert nur; ein zweiter Lauf bricht mit cannot re-use a name ab. Für Aktualisierungen ist helm upgrade --install der Aufruf – so steht es weiter unten bei den Fachdiensten, die häufig neu ausgerollt werden.
--set fullnameOverride=postgres usw. alle vier Erzwingt den Service-Namen exakt so. Helm hängt sonst den Release-Namen davor (postgres-postgresql); die Konfiguration der Anwendung kennt aber nur postgres, redis, kafka und keycloak. Genau diese Namen löst Kubernetes im Cluster-Netz als Hostnamen auf – kein DNS-Eintrag nötig.
--set auth.username=library --set auth.password=library Postgres Legt Benutzer und Passwort beim ersten Start an. Nur beim ersten Start – das Chart schreibt sie in das Datenverzeichnis; eine spätere Änderung des Werts bleibt wirkungslos, solange das Volume besteht. Passwörter gehören hier nur zu Uebungszwecken in die Kommandozeile; in Produktion kommen sie aus einem Secret.
--set auth.enabled=false Redis Kein Passwort. Redis ist nur im Cluster-Netz erreichbar und dient als Cache – vertretbar, solange kein Ingress darauf zeigt. Ohne den Schalter erzeugt das Chart selbst ein Passwort, und die Dienste kaemen nicht hinein.
--set listeners.client.protocol=PLAINTEXT Kafka Kein TLS und kein SASL zwischen den Diensten und Kafka. Für einen Lern-Cluster angemessen – die Vorgabe des Charts wäre SASL, und dann braeuchte jeder Dienst zusätzlich Zugangsdaten.
--set auth.adminPassword='<pw>' Keycloak Die einfachen Anführungszeichen sind in PowerShell wichtig, sobald das Passwort Sonderzeichen enthält – ein $ darin würde sonst als Variable gelesen und stillschweigend durch nichts ersetzt.
` am Zeilenende PowerShell Zeilenfortsetzung – das Gegenstück zum \ der Shell. Es muss das letzte Zeichen der Zeile sein; ein Leerzeichen dahinter zerlegt den Befehl still in unvollständige Teile.
  • Weg 2: ein infra/k8s/-Ordner mit schlichten Deployment+Service-Manifesten analog zur Compose-Datei – mehr Kontrolle, mehr Tipparbeit.
  • Weg 3: Managed (Neon/Aiven Postgres, Confluent/Redpanda Kafka, CloudAMQP, Upstash Redis) und die Env in den values auf die externen Hosts zeigen lassen.
  • Postgres: die 7 logischen DBs anlegen – init-multiple-dbs.sh als ConfigMap + initdbScripts, oder manuell CREATE DATABASE catalog_db;
  • Keycloak: realm-library.json als ConfigMap mounten, --import-realm, KC_HOSTNAME = Ingress-URL.

10 Stufe C – Helm-Deployment der Services

Laptop · PowerShell (→ Cluster)
# DB-Secret pro Service (das Chart erzeugt bewusst keins):
kubectl create secret generic catalog-service-db-credentials -n library `
  --from-literal=SPRING_DATASOURCE_USERNAME=library `
  --from-literal=SPRING_DATASOURCE_PASSWORD='<echtes-pw>'

# alle 8 Services deployen (discovery-server bleibt aussen vor):
$sha = git rev-parse --short HEAD
Get-ChildItem infra/helm/values/*-values.yaml | ForEach-Object {
  $n = $_.BaseName -replace '-values$',''
  helm upgrade --install $n infra/helm/library-service -f $_.FullName -n library `
    --set image.repository="ghcr.io/<user>/$n" --set image.tag=$sha
}
Was die Schalter bedeuten

Ein Secret von Hand, dann acht Deployments aus einer Schleife – die Schleife ist der interessante Teil:

KonstruktStellebewirkt
kubectl create secret generic Secret Es gibt mehrere Sorten Secret. generic ist die freie Form für beliebige Schlüssel-Wert-Paare; daneben gibt es docker-registry (für Zugangsdaten zu einer Registry, weiter unten in dieser Anleitung) und tls für Zertifikate. Kubernetes verschlüsselt Secrets nicht – sie sind nur Base64-kodiert und damit vor allem getrennt vom Rest der Konfiguration, nicht geheim.
--from-literal=NAME=wert Secret Wert direkt aus der Kommandozeile statt aus einer Datei (--from-file). kubectl kodiert selbst nach Base64 – wer den Wert vorher von Hand kodiert, bekommt ein doppelt kodiertes Secret und einen Dienst, der sich mit unverständlicher Meldung nicht anmelden kann. Die Namen sind bewusst die Spring-Umgebungsvariablen, damit das Chart sie unverändert durchreichen kann.
zweimal ausgeführt → AlreadyExists Secret Dieser Aufruf ist nicht wiederholbar. Wer ihn regelmäßig braucht, hängt --dry-run=client -o yaml | kubectl apply -f - an: dann berechnet kubectl das Objekt nur, gibt es aus, und apply legt es an oder aktualisiert es. Dasselbe Muster macht aus jedem create einen wiederholbaren Befehl.
$sha = git rev-parse --short HEAD Schleife Die Kurzform des gerade ausgecheckten Commits, sieben Zeichen. Sie wird unten der Image-Tag – und ist damit die einzige Stelle, an der der Bezug zwischen laufendem Container und Quellstand entsteht. latest wäre bequemer und würde genau diese Zuordnung zerstören.
Get-ChildItem …/*-values.yaml | ForEach-Object { … } Schleife Statt einer gepflegten Liste bestimmt der Ordnerinhalt, was ausgerollt wird: eine neue values-Datei genügt, damit ein Dienst mitkommt. Bequem – aber es heißt auch, dass eine versehentlich liegengebliebene Datei mit ausgerollt wird. ForEach-Object ist die Pipeline-Schleife; $_ darin ist die jeweilige Datei.
$_.BaseName -replace '-values$','' Schleife BaseName ist der Dateiname ohne Endung, also catalog-service-values. -replace arbeitet mit regulaeren Ausdrücken; das $ verankert am Ende, damit nur die Endung fällt und nicht ein gleichlautendes Stück mitten im Namen. Übrig bleibt der Release-Name catalog-service.
-f $_.FullName helm Der vollständige Pfad zur values-Datei. $_.Name wäre nur der Dateiname und Helm faende ihn nicht, sobald das Skript aus einem anderen Verzeichnis läuft.
helm upgrade --install helm Installiert beim ersten Lauf, aktualisiert bei jedem weiteren. Anders als beim Secret darüber ist dieser Aufruf also wiederholbar – deshalb kann die Schleife bei jedem Deployment einfach erneut durchlaufen.
--set image.repository=… --set image.tag=$sha helm --set gewinnt gegen die values-Datei. Genau diese beiden Werte gehören nicht in eine versionierte Datei: das Repository hängt vom eigenen Konto ab, der Tag ändert sich bei jedem Commit.
` am Zeilenende PowerShell Zeilenfortsetzung – PowerShells Gegenstück zum \ der Shell. Es muss das letzte Zeichen der Zeile sein; ein Leerzeichen dahinter zerlegt den Befehl still in unvollständige Teile.

Nur api-gateway bekommt einen Ingress. Im api-gateway-values.yaml ergänzen (k3s bringt Traefik mit, nicht nginx wie der Chart-Default):

Datei · infra/helm/values/api-gateway-values.yaml (Ergänzung)
ingress:
  enabled: true
  className: traefik
  host: api.lab.seb4u.com
Laptop · PowerShell
kubectl get pods -n library
kubectl get ingress -n library
helm list -n library

DNS (Domain Name System): api.lab.seb4u.com und auth.lab.seb4u.com → die LoadBalancer-IP von k3s. TLS (Transport Layer Security): cert-manager + Let’s Encrypt (Grundausstattung). Damit fällt kinds „kein Ingress, nur kubectl“-Einschränkung weg.

Domain schon bei Cloudflare

Liegt seb4u.com als Full-Zone bei Cloudflare (DNS + Registrar – siehe Domain zu Cloudflare umziehen), legst du in der bestehenden Zone einfach api.lab und auth.lab (oder gleich ein Wildcard *.lab) als A-Record auf die LB-IP an – alle „DNS only“ (grau): Traefik/cert-manager machen TLS selbst, und ein proxied Wildcard braucht Cloudflare Enterprise. Kein Konto, keine Nameserver.

11 Images bauen & in eine Registry

Die CI baut die 9 Images aktuell nur („Image bauen (nicht pushen)“). Fürs Deploy müssen sie in einer Registry liegen, die der Cluster erreicht.

Laptop / CI · PowerShell, im Ordner library-platform/
echo $env:GH_PAT | podman login ghcr.io -u <user> --password-stdin

podman build -f Dockerfile --build-arg MODULE=catalog-service `
  -t ghcr.io/<user>/catalog-service:$(git rev-parse --short HEAD) .
podman push ghcr.io/<user>/catalog-service:$(git rev-parse --short HEAD)
Laptop · PowerShell (→ Cluster) – privates Image ziehen
kubectl create secret docker-registry ghcr -n library `
  --docker-server=ghcr.io --docker-username=<user> --docker-password=$env:GH_PAT

# dem default-ServiceAccount zuweisen (oder im Chart imagePullSecrets setzen):
kubectl patch serviceaccount default -n library `
  -p '{"imagePullSecrets":[{"name":"ghcr"}]}'

Alternativ eine Registry im Cluster (registry:2 als Deployment) oder Harbor. Tag-Strategie: :<git-sha> fürs Deploy (reproduzierbar), :latest nur zum Spielen.

12 Secrets richtig

  • Tailnet, nichts öffentlich (Stufe A / B ohne Traefik): die Repo-Testcreds sind tolerierbar – niemand von außen kommt ran.
  • Sobald etwas öffentlich ist: Postgres-, Keycloak-Admin-, RabbitMQ-Passwörter neu und stark. Keycloak-Admin-Konsole nicht öffentlich.
  • Compose: .env-Datei (Modus 600, in .gitignore), env_file: statt Klartext im YAML (YAML Ain't Markup Language).
  • k8s: kubectl create secret (siehe Schritt 10), oder External Secrets Operator / Sealed Secrets – die Quelle kann dein Vaultwarden sein.
  • Nie die Klartext-.env oder das Repo mit Testcreds auf einen öffentlich erreichbaren Server rsyncen.

+ CI-Deploy-Stage

Jenkinsfile / GH-Actions hören beim Build (bzw. kind-Deploy) auf – der „bewusste Cut“. Eine Deploy-Stage schließt den Loop.

Datei · .github/workflows/ci.yml – Stufe A/B (rsync + compose)
  deploy:
    needs: [container-build]
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: tailscale/github-action@v3
        with:
          oauth-client-id: ${{ secrets.TS_OAUTH_ID }}
          oauth-secret: ${{ secrets.TS_OAUTH_SECRET }}
          tags: tag:ci
      - run: |
          rsync -az infra/ root@stack-host.dein-tailnet.ts.net:/opt/stack/infra/
          ssh root@stack-host.dein-tailnet.ts.net \
            'cd /opt/stack/infra && podman compose pull && podman compose up -d'
Datei · .github/workflows/ci.yml – Stufe C (helm)
      - run: |
          echo "${{ secrets.KUBECONFIG_B64 }}" | base64 -d > kc.yaml
          export KUBECONFIG=kc.yaml
          for v in infra/helm/values/*-values.yaml; do
            n=$(basename "$v" -values.yaml)
            helm upgrade --install "$n" infra/helm/library-service -f "$v" -n library \
              --set image.repository=ghcr.io/<user>/$n --set image.tag=${{ github.sha }}
          done

Secrets im Repo: Tailscale-OAuth, GHCR-PAT, Cluster-kubeconfig (base64). Jenkins selbst (liegt schon in shared/enterprise-infrastructure/jenkins/) auf einer Hetzner-Box (container-host-Guide) oder als k3s-Workload → dein Dauer-CI/CD (Continuous Delivery) für alle Projekte.

+ Kosten & Betrieb

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.

StufeServerca. €/Monat
A – nur InfraCPX22CPX32 + IPv4 (CAX ~11–21)~20–36
B – ganzer Stack + TraefikCPX42 + IPv4 + Domain~70
C – k3s 1 NodeCPX42/CPX52 + Domain~70–100
C – k3s 3 Nodes + LBCPX32 + LB~114

„3 Nodes + LB“ – sind das 3 oder 4 Server?

3 Server. Der Load Balancer ist kein Server: keine vCPU/RAM, kein SSH (Secure Shell), du verwaltest nichts darauf. Er ist eine eigene, verwaltete Hetzner-Ressource mit eigener Rechnungszeile – nimmt Port 80/443 von außen an und verteilt per Health-Check auf die 3 Nodes. lb11 reicht (~5,4 €/Mo, Traffic inklusive).

HA (Hochverfügbarkeit) heißt „ein Teil darf ausfallen, ohne dass der Dienst weg ist“:

  • 1 Node (Zeile „C – k3s 1 Node“): kein HA. Node kaputt/Neustart → Cluster und alle Dienste offline.
  • 3 Nodes mit embedded etcd (--cluster-init): die Control Plane überlebt den Ausfall eines Nodes (etcd-Quorum 2 von 3). Pods vom toten Node starten auf den anderen neu.
  • LB davor: nimmt einen toten Node aus dem Eingang. Ohne LB zeigt DNS auf eine einzelne Node-IP – dann ist der Eingang wieder ein Single Point of Failure. Günstiger geht das mit k3s-ServiceLB (Klipper, gratis, auf jedem Node) + Round-Robin-DNS auf alle Node-IPs – groeber, ohne Health-Checks.

Fürs Üben: 1 Node reicht, oder 3 Nodes ohne LB. „Echtes“ HA = 3 Nodes und LB.

Was ist ein Server, was nur ein Rechnungsposten?

Hetzner-Ressourceeigener Server?kostet extra?~Preis
Cloud-Server (cx/cax/ccx)jajaab ~5,5 €/Mo
Load Balancer (lb11/21/31)nein – managedja~5,4 / 11,9 / 21,9 €/Mo, Traffic inkl.
Volume (Block-Storage)neinja~0,044 €/GB (Gigabyte)/Mo (min. 10 GB)
Primary-IP (IPv4)neinja~0,50 €/Mo je IPv4 (auch losgeloest), IPv6 (Internet Protocol Version 6) gratis
Snapshotneinja~0,012–0,014 €/GB/Mo
Backups (automatisch)nein – Server-Featureja+20 % vom Serverpreis
Object Storage (S3-kompatibel)neinjaab ~6 €/Mo (1 TB inkl.)
Privates Netzwerkneinnein – gratis
Firewallneinnein – gratis
Placement Groupneinnein – gratis
TLS-Zertifikat (managed, am LB)neinnein – gratisLet’s Encrypt
Trafficerst über 20 TB (Terabyte)/Serverdann ~1 €/TB

Preise sind Richtwerte (Stand 2026) – bei Hetzner nachsehen. Jede kostenpflichtige Ressource taucht einzeln auf der Rechnung auf, auch wenn kein Server dranhängt (z. B. eine losgeloeste Primary-IP oder ein Volume nach dem Löschen des Servers).

Alles stundenweise, gedeckelt auf den Monatspreis – Server, LB (lb11 ~0,008 €/h), Volumes, Primary-IPs, Snapshots (GB·Stunde). Nur Löschen stoppt die Uhr. Ein gestoppter Server zählt weiter. Deshalb räumt ein Kill-Switch wie scripts\cluster.ps1 down (OKD-Guide) Server, LB und Netz in einem Rutsch weg.

  • Für Wegwerf-Sessions das An/Aus-Skript aus dem jeweiligen Guide: scripts\cloud.ps1 (Compose-Host), scripts\k3s.ps1 (Stufe C, ein k3s-Node) bzw. scripts\cluster.ps1 (OKD-Cluster) – der Snapshot enthält den ganzen Stack inkl. Images.
  • Mehrere k3s-Nodes + Load Balancer: k3s.ps1 deckt nur den einen Node ab. Für den kompletten Abriss (alle Nodes + LB + Netz) cluster.ps1 kopieren und $Prefix = 'k3s-' setzen – es arbeitet rein über das Namenspraefix.
  • Für eine Demo, die stehen soll: kein Auto-Teardown, klein dimensionieren, Backups.
  • RAM beobachten: podman stats / kubectl top pods. Die resources.requests in den values sind bewusst niedrig (384 Mi) – realer Bedarf je Service oft 512–768 Mi.

+ Managed-Infra statt selbst betreiben

Für etwas Dauerhaftes mit wenig Pflege – die Infra-Bausteine einzeln auslagern:

BausteinManaged-OptionenFree-Tier
PostgreSQLNeon (serverless), Aiven (EU), SupabaseNeon & Supabase ja
RedisUpstash (serverless), AivenUpstash ja
KafkaConfluent Cloud, Redpanda Cloud, Aivenklein / zeitlich
RabbitMQCloudAMQP„Little Lemur“ gratis
Keycloakkein gutes Managed – selbst hosten, oder Zitadel Cloud / Auth0 / Clerk als ErsatzAuth0/Clerk ja
Metriken/LogsGrafana Cloudgroßzügig

Für ein Lab: alles selbst (diese Anleitung). Für eine dauerhafte Demo: Postgres + Redis managed, Kafka nur wenn wirklich gebraucht, Keycloak selbst.

⌂ Cockpit