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
|
# testing
|
||||||
/coverage
|
/coverage
|
||||||
|
/test-results
|
||||||
|
/playwright-report
|
||||||
|
/blob-report
|
||||||
|
|
||||||
# next.js
|
# next.js
|
||||||
/.next/
|
/.next/
|
||||||
@@ -30,8 +33,9 @@ yarn-debug.log*
|
|||||||
yarn-error.log*
|
yarn-error.log*
|
||||||
.pnpm-debug.log*
|
.pnpm-debug.log*
|
||||||
|
|
||||||
# env files (can opt-in for committing if needed)
|
# env files (Vorlage ohne echte Geheimnisse bleibt bewusst versioniert)
|
||||||
.env*
|
.env*
|
||||||
|
!.env.example
|
||||||
|
|
||||||
# vercel
|
# vercel
|
||||||
.vercel
|
.vercel
|
||||||
@@ -39,3 +43,9 @@ yarn-error.log*
|
|||||||
# typescript
|
# typescript
|
||||||
*.tsbuildinfo
|
*.tsbuildinfo
|
||||||
next-env.d.ts
|
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