diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..cf599ae --- /dev/null +++ b/.dockerignore @@ -0,0 +1,13 @@ +node_modules +.next +.git +.env +.env.local +npm-debug.log* +*.md +.vscode +src/generated +test-results +playwright-report +blob-report +coverage diff --git a/.gitea/workflows/docker-build.yml b/.gitea/workflows/docker-build.yml new file mode 100644 index 0000000..b9aef21 --- /dev/null +++ b/.gitea/workflows/docker-build.yml @@ -0,0 +1,81 @@ +name: Docker Image bauen und veroeffentlichen + +# Laeuft bei jedem Push auf main (rollierendes ":latest" + ":") +# und bei Versions-Tags wie "v1.2.3" (zusaetzlich als eigener Tag). Manuell +# ueber "Run workflow" in der Gitea-Weboberflaeche ebenfalls startbar. +on: + push: + branches: [main] + tags: ["v*"] + workflow_dispatch: + +jobs: + build-and-push: + runs-on: ubuntu-latest + steps: + - name: Code auschecken + uses: actions/checkout@v4 + + - name: Image-Namen in Kleinbuchstaben ermitteln + # OCI-Registries erlauben nur Kleinbuchstaben im Repository-Pfad; + # ${{ gitea.repository }} ("Owner/Repo") behaelt die Gross-/ + # Kleinschreibung des Gitea-Repos bei. + id: image + run: echo "name=$(echo '${{ gitea.repository }}' | tr '[:upper:]' '[:lower:]')" >> "$GITEA_OUTPUT" + + - name: Docker Buildx einrichten + uses: docker/setup-buildx-action@v3 + + - name: Bei der Gitea-Container-Registry anmelden + uses: docker/login-action@v3 + with: + registry: ${{ gitea.server_url }} + username: ${{ gitea.actor }} + # GITEA_TOKEN hat aktuell KEIN Package-Write-Recht (Gitea- + # Einschraenkung, Stand heute) — deshalb ein separates Personal + # Access Token als Repo-/Org-Secret REGISTRY_TOKEN anlegen + # (Scope: "package", Gitea-Einstellungen -> Anwendungen -> + # Access Tokens) und unter Repo-Einstellungen -> Actions -> + # Secrets als REGISTRY_TOKEN hinterlegen. + password: ${{ secrets.REGISTRY_TOKEN }} + + - name: Metadaten (Tags/Labels) ermitteln + id: meta + uses: docker/metadata-action@v5 + with: + images: ${{ gitea.server_url }}/${{ steps.image.outputs.name }} + tags: | + type=raw,value=latest,enable={{is_default_branch}} + type=sha,format=short + type=semver,pattern={{version}} + + # Laufzeit-Image (schlank, "output: standalone") — das ist das Image, + # das in Kubernetes tatsaechlich den Shop ausliefert (siehe + # k8s/app-deployment.yaml). Ein einzelnes Image traegt beide Prisma- + # Client-Varianten (Postgres + MySQL) und waehlt zur Laufzeit per + # DATABASE_PROVIDER, siehe src/lib/prisma.ts. + - name: Laufzeit-Image bauen und pushen + uses: docker/build-push-action@v6 + with: + context: . + push: true + tags: ${{ steps.meta.outputs.tags }} + labels: ${{ steps.meta.outputs.labels }} + cache-from: type=gha + cache-to: type=gha,mode=max + + # Migrations-Image: dieselbe Build-Stage wie oben, aber vor dem + # schlanken Runtime-Stage abgeschnitten (--target=builder) — enthaelt + # zusaetzlich die Prisma-CLI und tsx, die "pnpm db:push"/"pnpm db:seed" + # brauchen (im Laufzeit-Image bewusst NICHT enthalten, um es klein zu + # halten). Wird vom Kubernetes-Migrations-Job verwendet, siehe + # k8s/migrate-job.yaml. + - name: Migrations-Image bauen und pushen + uses: docker/build-push-action@v6 + with: + context: . + target: builder + push: true + tags: ${{ gitea.server_url }}/${{ steps.image.outputs.name }}:migrate-${{ gitea.sha }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.gitignore b/.gitignore index 5ef6a52..b18a2e4 100644 --- a/.gitignore +++ b/.gitignore @@ -12,6 +12,9 @@ # testing /coverage +/test-results +/playwright-report +/blob-report # next.js /.next/ @@ -30,8 +33,9 @@ yarn-debug.log* yarn-error.log* .pnpm-debug.log* -# env files (can opt-in for committing if needed) +# env files (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert) .env* +!.env.example # vercel .vercel @@ -39,3 +43,9 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts + +/src/generated/prisma +/src/generated/prisma-mysql + +# kubernetes (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert) +/k8s/02-secret.yaml diff --git a/Caddyfile b/Caddyfile new file mode 100644 index 0000000..92da11d --- /dev/null +++ b/Caddyfile @@ -0,0 +1,18 @@ +{ + # Fuer eine echte Domain mit automatischem HTTPS: {$SITE_ADDRESS} unten auf + # z.B. "wabenhain.com" setzen und diesen Block entfernen. Ohne echte + # Domain kann Caddy kein Zertifikat ausstellen, daher hier bewusst + # lokal-tauglich per HTTP eingerichtet. + auto_https off +} + +{$SITE_ADDRESS::80} { + # Terminiert die tatsaechliche Client-Verbindung und ueberschreibt + # X-Forwarded-For dabei komplett mit der selbst beobachteten IP (Caddy + # verwirft eingehende X-Forwarded-For/-Proto/-Host-Header von Clients + # per Default, siehe https://caddyserver.com/docs/caddyfile/directives/reverse_proxy#defaults). + # Ohne diesen Proxy koennte jeder Client den Header selbst faelschen und + # damit saemtliche Rate-Limits (Login, 2FA, Checkout, ...) umgehen, siehe + # lib/rate-limit.ts. + reverse_proxy app:3000 +} diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..171f599 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,46 @@ +# syntax=docker/dockerfile:1 + +FROM node:22.23.2-alpine3.24 AS base +RUN corepack enable +WORKDIR /app + +# --- Dependencies ----------------------------------------------------------- +FROM base AS deps +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +# --ignore-scripts: postinstall generiert die Prisma-Clients aus +# prisma/schema.prisma + scripts/ — in dieser Stage noch nicht vorhanden +# (erst "COPY . ." im builder-Stage unten). Die build-Stage generiert ueber +# "pnpm build" ohnehin beide Client-Varianten, s.u. +RUN pnpm install --frozen-lockfile --ignore-scripts + +# --- Build -------------------------------------------------------------- +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# DATABASE_URL wird zur Build-Zeit nicht benoetigt (Prisma generate braucht +# keine Live-Verbindung), muss aber gesetzt sein, damit prisma.config.ts +# (laedt selbst kein .env im Image) nicht wegen einer fehlenden URL abbricht. +ENV DATABASE_URL="postgresql://placeholder:placeholder@localhost:5432/placeholder" +# Generiert intern beide Prisma-Client-Varianten (Postgres + MySQL, siehe +# scripts/generate-prisma-clients.mjs) und baut danach die Next.js-App — ein +# einzelnes Image, das per DATABASE_PROVIDER zur Laufzeit zwischen beiden +# Datenbanken wechseln kann, siehe lib/prisma.ts. +RUN pnpm build + +# --- Runtime -------------------------------------------------------------- +FROM base AS runner +ENV NODE_ENV=production +RUN addgroup --system --gid 1001 nodejs \ + && adduser --system --uid 1001 nextjs + +COPY --from=builder /app/public ./public +COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ +COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static +COPY --from=builder /app/prisma ./prisma + +USER nextjs +EXPOSE 3000 +ENV PORT=3000 +ENV HOSTNAME="0.0.0.0" + +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..964e0d1 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,112 @@ +services: + db: + image: postgres:16.15-alpine + restart: unless-stopped + # Nur relevant, wenn DATABASE_PROVIDER=postgresql (Standard) — bei mysql + # stattdessen "docker compose --profile mysql up -d db-mysql" starten, + # siehe README, Abschnitt "Datenbank-Provider wechseln". + environment: + POSTGRES_USER: ${POSTGRES_USER:-wabenhain} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD muss in .env gesetzt sein} + POSTGRES_DB: ${POSTGRES_DB:-wabenhain} + ports: + # Nur an localhost binden statt an alle Interfaces — die App erreicht + # die DB ueber das interne Compose-Netzwerk, dieser Port dient nur + # lokalem Debugging (z.B. per psql/DB-GUI vom selben Host aus). + - "127.0.0.1:5432:5432" + volumes: + - wabenhain_db_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-wabenhain}"] + interval: 5s + timeout: 5s + retries: 10 + + # Alternative zu "db": nur starten, wenn DATABASE_PROVIDER=mysql in .env + # gesetzt ist ("docker compose --profile mysql up -d db-mysql"). Laeuft + # nicht automatisch mit "docker compose up" mit, um den Postgres-Standardfall + # nicht zu verlangsamen. + db-mysql: + image: mysql:8.4.11 + profiles: ["mysql"] + restart: unless-stopped + environment: + MYSQL_USER: ${MYSQL_USER:-wabenhain} + MYSQL_PASSWORD: ${MYSQL_PASSWORD:?MYSQL_PASSWORD muss in .env gesetzt sein, falls DATABASE_PROVIDER=mysql} + MYSQL_DATABASE: ${MYSQL_DATABASE:-wabenhain} + MYSQL_ALLOW_EMPTY_PASSWORD: "no" + MYSQL_RANDOM_ROOT_PASSWORD: "yes" + ports: + - "127.0.0.1:3306:3306" + volumes: + - wabenhain_db_mysql_data:/var/lib/mysql + healthcheck: + test: ["CMD-SHELL", "mysqladmin ping -h localhost -u${MYSQL_USER:-wabenhain} -p${MYSQL_PASSWORD}"] + interval: 5s + timeout: 5s + retries: 10 + + # Einziger nach aussen exponierter Dienst: terminiert die Client-Verbindung + # und setzt X-Forwarded-For dabei neu (verwirft einen vom Client selbst + # mitgeschickten Wert, siehe Caddyfile-Kommentar). Ohne diesen Proxy koennte + # "app" direkt erreicht werden und jedes Rate-Limit (Login, 2FA, Checkout) + # liesse sich durch einen gefaelschten X-Forwarded-For-Header umgehen. + proxy: + image: caddy:2.11.4-alpine + restart: unless-stopped + depends_on: + - app + ports: + - "80:80" + # Fuer echtes HTTPS mit eigener Domain zusaetzlich freigeben und + # SITE_ADDRESS in .env auf die Domain setzen (siehe Caddyfile): + # - "443:443" + # - "443:443/udp" + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - wabenhain_caddy_data:/data + environment: + SITE_ADDRESS: ${SITE_ADDRESS:-} + + app: + build: + context: . + dockerfile: Dockerfile + restart: unless-stopped + depends_on: + db: + condition: service_healthy + required: false + expose: + - "3000" + environment: + # DATABASE_URL kommt komplett aus .env — je nach DATABASE_PROVIDER + # entweder die Postgres- ("db"-Service, Port 5432) oder die + # MySQL-Verbindungszeichenkette ("db-mysql"-Service, Port 3306). + DATABASE_PROVIDER: ${DATABASE_PROVIDER:-postgresql} + DATABASE_URL: ${DATABASE_URL} + STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY} + STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY} + STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET} + # Nur fuer den Seed-Schritt relevant (legt das initiale Admin-Konto an, + # siehe README "Admin-Zugang") — kein Basic-Auth-Geheimnis mehr. Bewusst + # OHNE Default-Wert: ein vorhersehbarer Wert wie "admin@wabenhain.com" + # liesse sich vor dem ersten Deploy-Seed-Lauf selbst registrieren, siehe + # prisma/seed.ts fuer die zusaetzliche Absicherung dagegen. + ADMIN_EMAIL: ${ADMIN_EMAIL:?ADMIN_EMAIL muss in .env gesetzt sein} + ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD muss in .env gesetzt sein} + SESSION_SECRET: ${SESSION_SECRET:?SESSION_SECRET muss in .env gesetzt sein} + GOOGLE_PLACES_API_KEY: ${GOOGLE_PLACES_API_KEY} + GOOGLE_PLACE_ID: ${GOOGLE_PLACE_ID} + SMTP_HOST: ${SMTP_HOST} + SMTP_PORT: ${SMTP_PORT:-587} + SMTP_USER: ${SMTP_USER} + SMTP_PASSWORD: ${SMTP_PASSWORD} + SMTP_FROM: ${SMTP_FROM:-Wabenhain } + NEXT_PUBLIC_SITE_URL: ${NEXT_PUBLIC_SITE_URL:-http://localhost:3000} + NODE_ENV: production + +volumes: + wabenhain_db_data: + wabenhain_db_mysql_data: + wabenhain_caddy_data: diff --git a/k8s/00-namespace.yaml b/k8s/00-namespace.yaml new file mode 100644 index 0000000..7e46a15 --- /dev/null +++ b/k8s/00-namespace.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +kind: Namespace +metadata: + name: wabenhain diff --git a/k8s/01-configmap.yaml b/k8s/01-configmap.yaml new file mode 100644 index 0000000..bc23114 --- /dev/null +++ b/k8s/01-configmap.yaml @@ -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 " + # 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: "" diff --git a/k8s/02-secret.example.yaml b/k8s/02-secret.example.yaml new file mode 100644 index 0000000..cf24a73 --- /dev/null +++ b/k8s/02-secret.example.yaml @@ -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='change-me@example.com' \ +# --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: "change-me@example.com" + ADMIN_PASSWORD: "CHANGE-ME" + STRIPE_SECRET_KEY: "" + STRIPE_PUBLISHABLE_KEY: "" + STRIPE_WEBHOOK_SECRET: "" + SMTP_HOST: "" + SMTP_USER: "" + SMTP_PASSWORD: "" + TURNSTILE_SECRET_KEY: "" diff --git a/k8s/03-mysql.yaml b/k8s/03-mysql.yaml new file mode 100644 index 0000000..f9e06bd --- /dev/null +++ b/k8s/03-mysql.yaml @@ -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 diff --git a/k8s/03-postgres.yaml b/k8s/03-postgres.yaml new file mode 100644 index 0000000..7403542 --- /dev/null +++ b/k8s/03-postgres.yaml @@ -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 diff --git a/k8s/04-migrate-job.yaml b/k8s/04-migrate-job.yaml new file mode 100644 index 0000000..9a1e3a2 --- /dev/null +++ b/k8s/04-migrate-job.yaml @@ -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-"-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 diff --git a/k8s/05-app-deployment.yaml b/k8s/05-app-deployment.yaml new file mode 100644 index 0000000..108e1e6 --- /dev/null +++ b/k8s/05-app-deployment.yaml @@ -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 diff --git a/k8s/06-ingress.yaml b/k8s/06-ingress.yaml new file mode 100644 index 0000000..b5b3d39 --- /dev/null +++ b/k8s/06-ingress.yaml @@ -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 diff --git a/k8s/README.md b/k8s/README.md new file mode 100644 index 0000000..227753b --- /dev/null +++ b/k8s/README.md @@ -0,0 +1,92 @@ +# Wabenhain in Kubernetes + +Rohe Manifeste, mit `kubectl apply -f k8s/` 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: + - `//wabenhain:latest` (+ `:sha-`, `:vX.Y.Z` bei + Tags) — das schlanke Laufzeit-Image fuer `05-app-deployment.yaml`. + - `//wabenhain:migrate-` — 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.