feat: Docker-, Kubernetes- und Gitea-Actions-Deployment-Pipeline
Docker Image bauen und veroeffentlichen / build-and-push (push) Waiting to run

Dockerfile (Multi-Stage, "output: standalone"), docker-compose.yml mit
Caddy als einzigem nach aussen offenem Reverse-Proxy (verhindert
X-Forwarded-For-Faelschung fuer das App-eigene Rate-Limiting), Gitea
Actions Workflow der bei jedem Push zwei Image-Tags in die Gitea-
Container-Registry baut (schlankes Laufzeit-Image + ein Migrations-
Image mit Prisma-CLI), sowie rohe Kubernetes-Manifeste (Namespace,
ConfigMap, Secret-Vorlage, Postgres-/MySQL-StatefulSet, Migrations-Job,
App-Deployment, Ingress) fuer den Cluster-Betrieb. Siehe k8s/README.md
fuer den vollstaendigen Einrichtungs-/Deployment-Ablauf.
This commit is contained in:
Nick Szaunig
2026-08-17 14:30:46 +02:00
parent c7927f2883
commit 5889f58da7
15 changed files with 727 additions and 1 deletions
+4
View File
@@ -0,0 +1,4 @@
apiVersion: v1
kind: Namespace
metadata:
name: wabenhain
+23
View File
@@ -0,0 +1,23 @@
# Nicht-geheime Konfiguration. Geheimnisse (Passwoerter, API-Keys) gehoeren
# in 02-secret.yaml (aus 02-secret.example.yaml kopiert), NIE hierher.
apiVersion: v1
kind: ConfigMap
metadata:
name: wabenhain-config
namespace: wabenhain
data:
# "postgresql" oder "mysql" — muss zum Service passen, den du unter
# 03-postgres.yaml/03-mysql.yaml tatsaechlich ausrollst, UND zum
# DATABASE_URL-Host in 02-secret.yaml. Ein einzelnes Image traegt beide
# Prisma-Client-Varianten, siehe src/lib/prisma.ts.
DATABASE_PROVIDER: "postgresql"
NODE_ENV: "production"
# Oeffentliche URL des Shops (fuer absolute Links in E-Mails/Sitemaps/CSP).
NEXT_PUBLIC_SITE_URL: "https://shop.example.com"
SMTP_PORT: "587"
SMTP_FROM: "Wabenhain <[email protected]>"
# Optional, siehe README-Abschnitte "Google-Rezensionen"/"CAPTCHA" — leer
# lassen, wenn nicht genutzt, die App faellt automatisch zurueck.
GOOGLE_PLACES_API_KEY: ""
GOOGLE_PLACE_ID: ""
NEXT_PUBLIC_TURNSTILE_SITE_KEY: ""
+44
View File
@@ -0,0 +1,44 @@
# Vorlage OHNE echte Geheimnisse — analog zu .env.example. Kopieren nach
# 02-secret.yaml (per .gitignore ausgeschlossen, NIE committen), Werte
# ausfuellen, dann "kubectl apply -f k8s/02-secret.yaml".
#
# Alternative ohne Datei mit Klartext-Secrets auf der Platte:
# kubectl create secret generic wabenhain-secrets -n wabenhain \
# --from-literal=DATABASE_URL='postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public' \
# --from-literal=DB_PASSWORD='CHANGE-ME' \
# --from-literal=SESSION_SECRET="$(openssl rand -base64 32)" \
# --from-literal=ADMIN_EMAIL='[email protected]' \
# --from-literal=ADMIN_PASSWORD="$(openssl rand -base64 24 | tr -d '=+/' | head -c 28)" \
# --from-literal=STRIPE_SECRET_KEY='' \
# --from-literal=STRIPE_PUBLISHABLE_KEY='' \
# --from-literal=STRIPE_WEBHOOK_SECRET='' \
# --from-literal=SMTP_HOST='' --from-literal=SMTP_USER='' --from-literal=SMTP_PASSWORD='' \
# --from-literal=TURNSTILE_SECRET_KEY=''
apiVersion: v1
kind: Secret
metadata:
name: wabenhain-secrets
namespace: wabenhain
type: Opaque
stringData:
# Host "wabenhain-postgres" bzw. "wabenhain-mysql" passend zum Service aus
# 03-postgres.yaml/03-mysql.yaml waehlen; Port 5432 (Postgres) bzw. 3306
# (MySQL). Muss zu DATABASE_PROVIDER in 01-configmap.yaml passen.
DATABASE_URL: "postgresql://wabenhain:CHANGE-ME@wabenhain-postgres:5432/wabenhain?schema=public"
# Dasselbe Passwort wie oben in DATABASE_URL — wird vom Postgres-/MySQL-
# StatefulSet (je nachdem, welches du anwendest) zum Anlegen des
# Datenbank-Nutzers verwendet.
DB_PASSWORD: "CHANGE-ME"
# Mindestens 32 Zeichen, z.B.: openssl rand -base64 32
SESSION_SECRET: "CHANGE-ME-min-32-zeichen-zufaelliger-string"
# Unvorhersehbar waehlen (nicht "admin@..."), siehe README "Admin-Zugang" —
# sonst koennte sich jemand die erwartete Adresse vorregistrieren.
ADMIN_EMAIL: "[email protected]"
ADMIN_PASSWORD: "CHANGE-ME"
STRIPE_SECRET_KEY: ""
STRIPE_PUBLISHABLE_KEY: ""
STRIPE_WEBHOOK_SECRET: ""
SMTP_HOST: ""
SMTP_USER: ""
SMTP_PASSWORD: ""
TURNSTILE_SECRET_KEY: ""
+70
View File
@@ -0,0 +1,70 @@
# Alternative zu 03-postgres.yaml (DATABASE_PROVIDER=mysql in
# 01-configmap.yaml). Nur eines von beiden anwenden.
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: wabenhain-mysql
namespace: wabenhain
spec:
serviceName: wabenhain-mysql
replicas: 1
selector:
matchLabels:
app: wabenhain-mysql
template:
metadata:
labels:
app: wabenhain-mysql
spec:
containers:
- name: mysql
image: mysql:8.4.11
ports:
- containerPort: 3306
env:
- name: MYSQL_USER
value: wabenhain
- name: MYSQL_DATABASE
value: wabenhain
- name: MYSQL_PASSWORD
valueFrom:
secretKeyRef:
name: wabenhain-secrets
key: DB_PASSWORD
- name: MYSQL_RANDOM_ROOT_PASSWORD
value: "yes"
volumeMounts:
- name: data
mountPath: /var/lib/mysql
readinessProbe:
exec:
command: ["mysqladmin", "ping", "-h", "localhost", "-uwabenhain", "-p$(MYSQL_PASSWORD)"]
initialDelaySeconds: 10
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
storage: 5Gi
---
apiVersion: v1
kind: Service
metadata:
name: wabenhain-mysql
namespace: wabenhain
spec:
clusterIP: None
selector:
app: wabenhain-mysql
ports:
- port: 3306
targetPort: 3306
+72
View File
@@ -0,0 +1,72 @@
# Standard-Datenbank (DATABASE_PROVIDER=postgresql in 01-configmap.yaml).
# Nur anwenden, wenn du Postgres nutzt — fuer MySQL stattdessen 03-mysql.yaml
# anwenden (nicht beide gleichzeitig).
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: wabenhain-postgres
namespace: wabenhain
spec:
serviceName: wabenhain-postgres
replicas: 1
selector:
matchLabels:
app: wabenhain-postgres
template:
metadata:
labels:
app: wabenhain-postgres
spec:
containers:
- name: postgres
image: postgres:16.15-alpine
ports:
- containerPort: 5432
env:
- name: POSTGRES_USER
value: wabenhain
- name: POSTGRES_DB
value: wabenhain
- name: POSTGRES_PASSWORD
valueFrom:
secretKeyRef:
name: wabenhain-secrets
key: DB_PASSWORD
volumeMounts:
- name: data
mountPath: /var/lib/postgresql/data
readinessProbe:
exec:
command: ["pg_isready", "-U", "wabenhain"]
initialDelaySeconds: 5
periodSeconds: 5
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
volumeClaimTemplates:
- metadata:
name: data
spec:
accessModes: ["ReadWriteOnce"]
resources:
requests:
# Bei Bedarf anpassen — abhaengig vom StorageClass deines Clusters.
storage: 5Gi
---
apiVersion: v1
kind: Service
metadata:
name: wabenhain-postgres
namespace: wabenhain
spec:
# Headless (kein eigener ClusterIP) — Standard fuer StatefulSets, DNS-Name
# "wabenhain-postgres.wabenhain.svc.cluster.local" bleibt stabil.
clusterIP: None
selector:
app: wabenhain-postgres
ports:
- port: 5432
targetPort: 5432
+37
View File
@@ -0,0 +1,37 @@
# Einmalig VOR jedem Deployment (bzw. bei jedem neuen Image-Tag) ausfuehren:
# kubectl delete job wabenhain-migrate -n wabenhain --ignore-not-found
# kubectl apply -f k8s/04-migrate-job.yaml
# kubectl wait --for=condition=complete job/wabenhain-migrate -n wabenhain --timeout=120s
# "kubectl delete...--ignore-not-found" zuerst, weil Jobs nicht ueberschrieben
# werden koennen (Kubernetes-Job-Objekte sind unveraenderlich) — ohne das
# schlaegt ein erneutes "kubectl apply" mit demselben Namen fehl.
#
# Nutzt das ":migrate-<sha>"-Image aus der Gitea-Actions-Pipeline (hat die
# Prisma-CLI + tsx an Bord, die "pnpm db:deploy"/"pnpm db:seed" brauchen —
# das schlanke Laufzeit-Image aus 05-app-deployment.yaml bewusst nicht,
# siehe Dockerfile). image: unten mit der tatsaechlichen Registry/Tag
# ersetzen.
apiVersion: batch/v1
kind: Job
metadata:
name: wabenhain-migrate
namespace: wabenhain
spec:
backoffLimit: 2
template:
spec:
restartPolicy: Never
containers:
- name: migrate
image: git.example.com/OWNER/wabenhain:migrate-REPLACE-ME
# prisma migrate deploy (nicht "db push"): wendet die committeten
# Migrationsdateien nicht-interaktiv an, das ist Prismas
# vorgesehener Weg fuer Produktions-Deployments. db:seed danach
# legt/erneuert nur das Admin-Konto + Beispieldaten, ist idempotent
# (siehe prisma/seed.ts).
command: ["sh", "-c", "pnpm db:deploy && pnpm db:seed"]
envFrom:
- configMapRef:
name: wabenhain-config
- secretRef:
name: wabenhain-secrets
+67
View File
@@ -0,0 +1,67 @@
# image: unten mit der tatsaechlichen Registry/Tag aus der Gitea-Actions-
# Pipeline ersetzen (das schlanke Laufzeit-Image, NICHT ":migrate-...").
# Vor dem ersten Rollout einmal den Migrations-Job (04-migrate-job.yaml)
# erfolgreich durchlaufen lassen, sonst startet die App gegen eine leere,
# ungemigrateet Datenbank.
apiVersion: apps/v1
kind: Deployment
metadata:
name: wabenhain-app
namespace: wabenhain
spec:
# ACHTUNG bevor du hochskalierst: lib/rate-limit.ts haelt Login-/2FA-/
# Checkout-Limits aktuell im Prozessspeicher, nicht in einem gemeinsamen
# Store. Mit replicas > 1 verteilt sich derselbe Nutzer per Load-Balancing
# auf mehrere Prozesse mit je eigenem Zaehler — das Limit wird effektiv um
# den Skalierungsfaktor schwaecher (2 Replicas = 2x mehr Versuche moeglich),
# ohne dass das im Frontend sichtbar waere. Fuer replicas > 1 zuerst
# lib/rate-limit.ts auf einen gemeinsamen Store (z.B. Redis) umstellen.
replicas: 1
selector:
matchLabels:
app: wabenhain-app
template:
metadata:
labels:
app: wabenhain-app
spec:
containers:
- name: app
image: git.example.com/OWNER/wabenhain:latest
ports:
- containerPort: 3000
envFrom:
- configMapRef:
name: wabenhain-config
- secretRef:
name: wabenhain-secrets
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 10
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 20
periodSeconds: 30
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
---
apiVersion: v1
kind: Service
metadata:
name: wabenhain-app
namespace: wabenhain
spec:
selector:
app: wabenhain-app
ports:
- port: 80
targetPort: 3000
+37
View File
@@ -0,0 +1,37 @@
# Setzt einen bereits im Cluster laufenden Ingress-Controller voraus (z.B.
# ingress-nginx) sowie optional cert-manager fuer automatisches TLS. Host
# unten anpassen (muss zu NEXT_PUBLIC_SITE_URL in 01-configmap.yaml passen).
#
# X-Forwarded-For: ingress-nginx setzt den Header standardmaessig korrekt
# (verwirft einen vom Client mitgeschickten Wert), lib/rate-limit.ts vertraut
# genau darauf — bei einem ANDEREN Ingress-Controller/zusaetzlichen Proxy
# davor unbedingt pruefen, dass er sich genauso verhaelt, siehe Kommentar in
# lib/rate-limit.ts.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: wabenhain
namespace: wabenhain
annotations:
# Nur relevant, falls cert-manager installiert ist — sonst diese beiden
# Zeilen und den "tls:"-Block unten entfernen und Host per HTTP betreiben
# oder TLS auf andere Weise terminieren.
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- shop.example.com
secretName: wabenhain-tls
rules:
- host: shop.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: wabenhain-app
port:
number: 80
+92
View File
@@ -0,0 +1,92 @@
# Wabenhain in Kubernetes
Rohe Manifeste, mit `kubectl apply -f k8s/<datei>` einzeln oder als Ordner
anwendbar. Nummerierung = empfohlene Reihenfolge.
## Einmalige Vorbereitung
1. **Gitea Actions**: `.gitea/workflows/docker-build.yml` baut bei jedem
Push auf `main` zwei Image-Tags in der Gitea-Container-Registry:
- `<registry>/<owner>/wabenhain:latest` (+ `:sha-<kurzhash>`, `:vX.Y.Z` bei
Tags) — das schlanke Laufzeit-Image fuer `05-app-deployment.yaml`.
- `<registry>/<owner>/wabenhain:migrate-<sha>` — enthaelt zusaetzlich die
Prisma-CLI/tsx, fuer `04-migrate-job.yaml`.
Voraussetzung: ein Repo-/Org-Secret `REGISTRY_TOKEN` in Gitea anlegen
(Personal Access Token mit `package`-Scope, unter Benutzereinstellungen →
Anwendungen → Access Tokens erzeugen, dann unter Repo-Einstellungen →
Actions → Secrets als `REGISTRY_TOKEN` hinterlegen). Der automatisch von
Gitea Actions bereitgestellte `GITEA_TOKEN` reicht dafuer aktuell NICHT
aus (fehlendes Package-Write-Recht, Stand heute).
2. **Ein Docker-Image ist Provider-uebergreifend**: Prisma generiert den
Client Provider-spezifisch — ein `.next/standalone`-Build kann seinen
generierten Client nicht zur Laufzeit wechseln. Deshalb generiert
`scripts/generate-prisma-clients.mjs` (Teil von `pnpm build`, laeuft also
automatisch bei jedem Image-Build) BEIDE Varianten ins selbe Image; die
Wahl zwischen ihnen passiert zur Laufzeit ueber `DATABASE_PROVIDER`, siehe
`src/lib/prisma.ts`. Ein Image reicht also fuer Postgres UND MySQL — nur
`DATABASE_PROVIDER`/`DATABASE_URL` in `01-configmap.yaml`/
`02-secret.yaml` muessen zur tatsaechlich gewaehlten Datenbank passen.
3. `k8s/02-secret.example.yaml` nach `k8s/02-secret.yaml` kopieren (per
`.gitignore` ausgeschlossen — NIE mit echten Werten committen) und
ausfuellen. Alternativ direkt per `kubectl create secret`, siehe
Kommentar in der Beispieldatei.
4. `01-configmap.yaml`: `DATABASE_PROVIDER` (postgresql/mysql) und
`NEXT_PUBLIC_SITE_URL` (echte Domain) anpassen.
5. In `04-migrate-job.yaml` und `05-app-deployment.yaml` die
`image:`-Platzhalter (`git.example.com/OWNER/wabenhain:...`) durch die
tatsaechliche Registry/Owner/Tag aus Schritt 1 ersetzen. In
`06-ingress.yaml` `shop.example.com` durch die echte Domain ersetzen
(zweimal: `host:` und `tls.hosts`).
## Deployment
```bash
kubectl apply -f k8s/00-namespace.yaml
kubectl apply -f k8s/01-configmap.yaml
kubectl apply -f k8s/02-secret.yaml
# Nur EINE der beiden Datenbanken anwenden, passend zu DATABASE_PROVIDER:
kubectl apply -f k8s/03-postgres.yaml
# kubectl apply -f k8s/03-mysql.yaml
kubectl wait --for=condition=ready pod -l app=wabenhain-postgres -n wabenhain --timeout=120s
# Migrationen + Seed (Admin-Konto, Beispieldaten) — vor jedem neuen
# Image-Tag erneut ausfuehren:
kubectl delete job wabenhain-migrate -n wabenhain --ignore-not-found
kubectl apply -f k8s/04-migrate-job.yaml
kubectl wait --for=condition=complete job/wabenhain-migrate -n wabenhain --timeout=120s
kubectl apply -f k8s/05-app-deployment.yaml
kubectl apply -f k8s/06-ingress.yaml
```
## Bei einem neuen Image (Redeploy)
```bash
kubectl delete job wabenhain-migrate -n wabenhain --ignore-not-found
kubectl apply -f k8s/04-migrate-job.yaml
kubectl wait --for=condition=complete job/wabenhain-migrate -n wabenhain --timeout=120s
kubectl rollout restart deployment/wabenhain-app -n wabenhain
```
## Bekannte Grenzen dieser Manifeste
- **`replicas: 1`** in `05-app-deployment.yaml`, bewusst nicht mehr: das
Login-/2FA-/Checkout-Rate-Limiting (`lib/rate-limit.ts`) haelt seinen
Zustand im Prozessspeicher, nicht in einem gemeinsamen Store. Mehrere
Replicas wuerden das Limit unbemerkt proportional aufweichen. Fuer
horizontale Skalierung zuerst `lib/rate-limit.ts` auf Redis o.ae. umstellen.
- Kein `HorizontalPodAutoscaler`, kein `PodDisruptionBudget`, keine
`NetworkPolicy` — je nach Cluster/Anforderungen ergaenzen.
- Die Postgres-/MySQL-StatefulSets sind Single-Instance ohne Replikation/
Backup-Automation. Fuer produktiven Betrieb einen Managed-Datenbank-Dienst
oder einen dedizierten DB-Operator (z.B. CloudNativePG) in Erwaegung
ziehen — `DATABASE_URL` in `02-secret.yaml` einfach auf den externen
Endpunkt zeigen lassen, `03-postgres.yaml`/`03-mysql.yaml` dann nicht
anwenden.