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.
C: k3s + Helm-Deployment
ssh)
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).
| Stufe | Was läuft in der Cloud | Server | Wofür |
|---|---|---|---|
| A | nur 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. |
| B | der ganze compose-Stack inkl. aller Services, Traefik davor | CPX42 (8 vCPU / 16 GB) | Teilbare Demo / Staging unter echten URLs (Uniform Resource Locators). |
| C | k3s-Cluster, Infra als Workloads, Services per helm upgrade --install | CPX42/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) | |
|---|---|---|
| Reichweite | ein Host | Cluster aus mehreren Nodes |
| Bedienung | Befehle: up / down / pull | Soll-Zustand beschreiben, Cluster gleicht ab |
| Neustart nach Crash | restart: always – nur auf demselben Host | Self-Healing, auch bei Node-Ausfall (Pod woanders) |
| Skalieren | --scale web=3, ohne echtes Load-Balancing | Repliken + Service-LB + HPA (automatisch) |
| Rolling Update / Rollback | – (kurz weg beim Neustart) | health-gated, kubectl rollout undo |
| Ingress / LB | selbst davorstellen (Traefik/Caddy) | eingebaut (Service, Ingress) |
| Secrets, RBAC (Role-Based Access Control), Netzwerk-Policies | rudimentaer | eingebaut |
| Operator-/CRD-Oekosystem (cert-manager …) | – | ja |
| Bewegliche Teile / Lernkurve | wenig | viel |
- Compose, wenn: 1 Server, überschaubare Zahl Dienste,
„reboot →
restart: alwaysreicht“. Weniger, was kaputtgehen kann. Das ist auch derdienste-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…
curl -fsSL https://tailscale.com/install.sh | sh
tailscale up
tailscale status # Hostname merken, z.B. stack-host
# 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.
# 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"
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
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.
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
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.
services:
kafka:
environment:
KAFKA_ADVERTISED_LISTENERS: PLAINTEXT://kafka:29092,PLAINTEXT_HOST://stack-host.dein-tailnet.ts.net:9092,KIND_HOST://host.containers.internal:9094
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.
keycloak:
environment:
KC_HOSTNAME: stack-host.dein-tailnet.ts.net
KC_HOSTNAME_PORT: "8180"
KC_HOSTNAME_STRICT: "false"
KC_HTTP_ENABLED: "true"
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
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).resourcesspäter tunen. - Öffentlich: die enterprise-infrastructure-Traefik dazustarten. Statt
*.localhosteine echte Wildcard-DNS:*.lab.seb4u.com→<IP>(ein A-Record). - Die Traefik-Regeln von
Host(`pgadmin.library.localhost`)aufHost(`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).
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:
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>'
Auffällig ist, was fehlt: kein helm repo add. Diese Anleitung
holt die Charts direkt aus einer OCI-Registry:
| Schalter | gilt für | bewirkt |
|---|---|---|
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.shals ConfigMap +initdbScripts, oder manuellCREATE DATABASE catalog_db;… - Keycloak:
realm-library.jsonals ConfigMap mounten,--import-realm,KC_HOSTNAME= Ingress-URL.
10 Stufe C – Helm-Deployment der Services
# 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
}
Ein Secret von Hand, dann acht Deployments aus einer Schleife – die Schleife ist der interessante Teil:
| Konstrukt | Stelle | bewirkt |
|---|---|---|
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):
ingress:
enabled: true
className: traefik
host: api.lab.seb4u.com
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.
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.
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)
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-
.envoder 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.
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'
- 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.
| Stufe | Server | ca. €/Monat |
|---|---|---|
| A – nur Infra | CPX22–CPX32 + IPv4 (CAX ~11–21) | ~20–36 |
| B – ganzer Stack + Traefik | CPX42 + IPv4 + Domain | ~70 |
| C – k3s 1 Node | CPX42/CPX52 + Domain | ~70–100 |
| C – k3s 3 Nodes + LB | 3×CPX32 + 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-Ressource | eigener Server? | kostet extra? | ~Preis |
|---|---|---|---|
Cloud-Server (cx/cax/ccx) | ja | ja | ab ~5,5 €/Mo |
Load Balancer (lb11/21/31) | nein – managed | ja | ~5,4 / 11,9 / 21,9 €/Mo, Traffic inkl. |
| Volume (Block-Storage) | nein | ja | ~0,044 €/GB (Gigabyte)/Mo (min. 10 GB) |
| Primary-IP (IPv4) | nein | ja | ~0,50 €/Mo je IPv4 (auch losgeloest), IPv6 (Internet Protocol Version 6) gratis |
| Snapshot | nein | ja | ~0,012–0,014 €/GB/Mo |
| Backups (automatisch) | nein – Server-Feature | ja | +20 % vom Serverpreis |
| Object Storage (S3-kompatibel) | nein | ja | ab ~6 €/Mo (1 TB inkl.) |
| Privates Netzwerk | nein | nein – gratis | – |
| Firewall | nein | nein – gratis | – |
| Placement Group | nein | nein – gratis | – |
| TLS-Zertifikat (managed, am LB) | nein | nein – gratis | Let’s Encrypt |
| Traffic | – | erst über 20 TB (Terabyte)/Server | dann ~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.ps1deckt nur den einen Node ab. Für den kompletten Abriss (alle Nodes + LB + Netz)cluster.ps1kopieren 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. Dieresources.requestsin 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:
| Baustein | Managed-Optionen | Free-Tier |
|---|---|---|
| PostgreSQL | Neon (serverless), Aiven (EU), Supabase | Neon & Supabase ja |
| Redis | Upstash (serverless), Aiven | Upstash ja |
| Kafka | Confluent Cloud, Redpanda Cloud, Aiven | klein / zeitlich |
| RabbitMQ | CloudAMQP | „Little Lemur“ gratis |
| Keycloak | kein gutes Managed – selbst hosten, oder Zitadel Cloud / Auth0 / Clerk als Ersatz | Auth0/Clerk ja |
| Metriken/Logs | Grafana Cloud | groß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.