feat: Docker-, Kubernetes- und Gitea-Actions-Deployment-Pipeline
Docker Image bauen und veroeffentlichen / build-and-push (push) Waiting to run
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:
@@ -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
|
||||
@@ -0,0 +1,81 @@
|
||||
name: Docker Image bauen und veroeffentlichen
|
||||
|
||||
# Laeuft bei jedem Push auf main (rollierendes ":latest" + ":<kurzer-commit-hash>")
|
||||
# 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
|
||||
+11
-1
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
+46
@@ -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"]
|
||||
@@ -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 "[email protected]"
|
||||
# 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 <[email protected]>}
|
||||
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:
|
||||
@@ -0,0 +1,4 @@
|
||||
apiVersion: v1
|
||||
kind: Namespace
|
||||
metadata:
|
||||
name: wabenhain
|
||||
@@ -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: ""
|
||||
@@ -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: ""
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user