From c781c5d19df390415136acaf895db9d73249783e Mon Sep 17 00:00:00 2001 From: Florian Wilde Date: Sat, 25 Jul 2026 19:35:04 +0200 Subject: [PATCH] build: Docker-Betrieb und Standalone-Betrieb gleichwertig unterstuetzen Dieselbe Codebasis laeuft nativ auf dem Rechner oder komplett im Container. docker-compose.yml mit Profilen statt mehrerer Dateien: - "db" laeuft immer -> docker compose up -d db, App dann lokal per npm - --profile full -> zusaetzlich der App-Container - --profile tools -> Adminer als Datenbankoberflaeche Dockerfile als Multi-Stage-Build auf dem Next-Standalone-Output. In die Laufzeitschicht wandern zusaetzlich Prisma-CLI, Engines und Schema, damit der Entrypoint beim Start "migrate deploy" ausfuehren kann. Das Alpine-Image bekommt openssl fuer die Prisma-Engines, der Client wird zusaetzlich fuer linux-musl-openssl-3.0.x erzeugt. Der Container laeuft als unprivilegierter Benutzer und meldet seinen Zustand ueber /api/health. Der Entrypoint wartet in einer Schleife auf die Datenbank, ergaenzend zum pg_isready-Healthcheck und der depends_on-Bedingung. scripts/setup.mjs legt die .env aus der Vorlage an und erzeugt dabei ein zufaelliges APP_SECRET, damit die Tokens nicht mit dem Standardschluessel verschluesselt werden. Co-Authored-By: Claude Opus 5 (1M context) --- .env.example | 40 +++++++++++++++++++++++ Dockerfile | 62 +++++++++++++++++++++++++++++++++++ docker-compose.yml | 78 ++++++++++++++++++++++++++++++++++++++++++++ docker/entrypoint.sh | 30 +++++++++++++++++ scripts/setup.mjs | 65 ++++++++++++++++++++++++++++++++++++ 5 files changed, 275 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 docker-compose.yml create mode 100644 docker/entrypoint.sh create mode 100644 scripts/setup.mjs diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..78cfe8b --- /dev/null +++ b/.env.example @@ -0,0 +1,40 @@ +# ───────────────────────────────────────────────────────────── +# Nebula – Beispielkonfiguration +# Kopieren nach ".env" und Werte anpassen. +# (Die .env wird sowohl von Next.js als auch von docker compose gelesen.) +# ───────────────────────────────────────────────────────────── + +# ── Datenbank ──────────────────────────────────────────────── +# Standalone-Betrieb: App laeuft lokal, Postgres via "docker compose up -d db" +# oder als nativ installierter Dienst. +DATABASE_URL="postgresql://nebula:nebula@localhost:5432/nebula?schema=public" + +# Im Docker-Vollbetrieb (--profile full) setzt compose DATABASE_URL selbst +# auf den Service-Hostnamen "db" – der Wert oben wird dann nicht verwendet. + +# Zugangsdaten des Postgres-Containers aus docker-compose.yml +POSTGRES_USER="nebula" +POSTGRES_PASSWORD="nebula" +POSTGRES_DB="nebula" +# Host-Port, unter dem die Datenbank erreichbar ist +POSTGRES_PORT="5432" + +# ── Sicherheit ─────────────────────────────────────────────── +# Schluessel zur Verschluesselung der Git-Access-Tokens (AES-256-GCM). +# Erzeugen mit: openssl rand -base64 32 +# oder: node -e "console.log(require('crypto').randomBytes(32).toString('base64'))" +# oder: npm run setup (legt die .env inkl. Schluessel an) +# WICHTIG: Wird der Wert nachtraeglich geaendert, sind gespeicherte Tokens +# nicht mehr entschluesselbar und muessen neu hinterlegt werden. +APP_SECRET="bitte-aendern-mindestens-32-zeichen-langer-zufallswert" + +# ── App ────────────────────────────────────────────────────── +# Port innerhalb des Containers bzw. im Standalone-Betrieb +PORT="3000" +# Host-Port-Mapping fuer den App-Container (--profile full) +APP_PORT="3000" +# Host-Port fuer Adminer (--profile tools) +ADMINER_PORT="8080" + +# Timeout fuer Git-API-Aufrufe in Millisekunden +GIT_API_TIMEOUT_MS="15000" diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..4204884 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,62 @@ +# syntax=docker/dockerfile:1 + +# ───────────────────────────────────────────────────────────── +# Nebula – Produktions-Image (Next.js standalone + Prisma) +# Build: docker compose --profile full build +# Start: docker compose --profile full up -d +# ───────────────────────────────────────────────────────────── + +FROM node:22-alpine AS base +# openssl wird von den Prisma-Engines benoetigt, libc6-compat von Next.js. +RUN apk add --no-cache libc6-compat openssl +WORKDIR /app +ENV NEXT_TELEMETRY_DISABLED=1 + + +# ── Abhaengigkeiten ────────────────────────────────────────── +FROM base AS deps +COPY package.json package-lock.json* ./ +# prisma/ wird schon hier gebraucht, weil "postinstall" prisma generate ausfuehrt. +COPY prisma ./prisma +RUN if [ -f package-lock.json ]; then npm ci; else npm install; fi + + +# ── Build ──────────────────────────────────────────────────── +FROM base AS builder +COPY --from=deps /app/node_modules ./node_modules +COPY . . +# Zur Build-Zeit wird keine echte Datenbank kontaktiert (alle Seiten sind +# dynamisch), Prisma verlangt die Variable aber beim Generieren. +ENV DATABASE_URL="postgresql://build:build@127.0.0.1:5432/build?schema=public" +RUN npm run build + + +# ── Laufzeit ───────────────────────────────────────────────── +FROM base AS runner +ENV NODE_ENV=production +ENV PORT=3000 +ENV HOSTNAME=0.0.0.0 + +RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001 + +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 + +# Prisma-CLI, Engines und Schema fuer "migrate deploy" beim Containerstart. +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/prisma ./node_modules/prisma +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/@prisma ./node_modules/@prisma +COPY --from=builder --chown=nextjs:nodejs /app/node_modules/.prisma ./node_modules/.prisma +COPY --from=builder --chown=nextjs:nodejs /app/prisma ./prisma + +COPY docker/entrypoint.sh /usr/local/bin/entrypoint.sh +RUN chmod +x /usr/local/bin/entrypoint.sh + +USER nextjs +EXPOSE 3000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ + CMD wget -q --spider http://127.0.0.1:3000/api/health || exit 1 + +ENTRYPOINT ["/usr/local/bin/entrypoint.sh"] +CMD ["node", "server.js"] diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..1f801a4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +name: nebula + +# ───────────────────────────────────────────────────────────── +# Zwei Betriebsarten aus einer Datei: +# +# 1) Standalone-Entwicklung – nur die Datenbank im Container: +# docker compose up -d db +# npm run dev +# +# 2) Alles im Container: +# docker compose --profile full up -d --build +# +# Optionales DB-Webinterface: +# docker compose --profile tools up -d adminer → http://localhost:8080 +# ───────────────────────────────────────────────────────────── + +services: + db: + image: postgres:17-alpine + container_name: nebula-db + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER:-nebula} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-nebula} + POSTGRES_DB: ${POSTGRES_DB:-nebula} + # Deterministische Sortierung unabhaengig vom Host-Locale + POSTGRES_INITDB_ARGS: "--encoding=UTF8" + ports: + - "${POSTGRES_PORT:-5432}:5432" + volumes: + - nebula-db-data:/var/lib/postgresql/data + healthcheck: + test: + ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-nebula} -d ${POSTGRES_DB:-nebula}"] + interval: 5s + timeout: 5s + retries: 20 + start_period: 10s + + app: + profiles: ["full"] + build: + context: . + dockerfile: Dockerfile + image: nebula-app:latest + container_name: nebula-app + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + DATABASE_URL: postgresql://${POSTGRES_USER:-nebula}:${POSTGRES_PASSWORD:-nebula}@db:5432/${POSTGRES_DB:-nebula}?schema=public + # Unbedingt in der .env ueberschreiben – verschluesselt die Git-Tokens. + APP_SECRET: ${APP_SECRET:-nebula-dev-secret-bitte-in-der-env-aendern} + GIT_API_TIMEOUT_MS: ${GIT_API_TIMEOUT_MS:-15000} + NODE_ENV: production + PORT: 3000 + HOSTNAME: 0.0.0.0 + ports: + - "${APP_PORT:-3000}:3000" + + adminer: + profiles: ["tools"] + image: adminer:latest + container_name: nebula-adminer + restart: unless-stopped + depends_on: + db: + condition: service_healthy + environment: + ADMINER_DEFAULT_SERVER: db + ADMINER_DESIGN: dracula + ports: + - "${ADMINER_PORT:-8080}:8080" + +volumes: + nebula-db-data: + name: nebula-db-data diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..b1b5f9c --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,30 @@ +#!/bin/sh +# ───────────────────────────────────────────────────────────── +# Startet Nebula im Container: erst Migrationen, dann der Server. +# ───────────────────────────────────────────────────────────── +set -e + +PRISMA_CLI="/app/node_modules/prisma/build/index.js" +SCHEMA="/app/prisma/schema.prisma" + +if [ -z "$DATABASE_URL" ]; then + echo "[nebula] FEHLER: DATABASE_URL ist nicht gesetzt." >&2 + exit 1 +fi + +echo "[nebula] Migrationen werden eingespielt…" + +attempt=0 +max_attempts=15 +until node "$PRISMA_CLI" migrate deploy --schema "$SCHEMA"; do + attempt=$((attempt + 1)) + if [ "$attempt" -ge "$max_attempts" ]; then + echo "[nebula] FEHLER: Migration nach $max_attempts Versuchen fehlgeschlagen." >&2 + exit 1 + fi + echo "[nebula] Datenbank noch nicht bereit – neuer Versuch in 3s ($attempt/$max_attempts)" + sleep 3 +done + +echo "[nebula] Migrationen ok. Server startet auf Port ${PORT:-3000}." +exec "$@" diff --git a/scripts/setup.mjs b/scripts/setup.mjs new file mode 100644 index 0000000..6c9f35e --- /dev/null +++ b/scripts/setup.mjs @@ -0,0 +1,65 @@ +#!/usr/bin/env node +/** + * Nebula – Ersteinrichtung. + * + * Legt eine .env auf Basis von .env.example an und erzeugt dabei einen + * zufaelligen APP_SECRET. Vorhandene .env-Dateien werden nicht angefasst. + * + * node scripts/setup.mjs bzw. npm run setup + */ + +import { randomBytes } from "node:crypto"; +import { existsSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const envPath = join(root, ".env"); +const examplePath = join(root, ".env.example"); + +const c = { + reset: "", + dim: "", + green: "", + yellow: "", + cyan: "", +}; + +function log(message) { + console.log(message); +} + +if (!existsSync(examplePath)) { + console.error(`${c.yellow}.env.example nicht gefunden – abgebrochen.${c.reset}`); + process.exit(1); +} + +if (existsSync(envPath)) { + log(`${c.yellow}·${c.reset} .env existiert bereits – sie wird nicht ueberschrieben.`); +} else { + const secret = randomBytes(32).toString("base64"); + const content = readFileSync(examplePath, "utf8").replace( + /^APP_SECRET=.*$/m, + `APP_SECRET="${secret}"`, + ); + writeFileSync(envPath, content, "utf8"); + log(`${c.green}✓${c.reset} .env angelegt (APP_SECRET wurde zufaellig erzeugt).`); +} + +log(""); +log(`${c.cyan}Naechste Schritte${c.reset}`); +log(`${c.dim}─────────────────────────────────────────────${c.reset}`); +log("Standalone (App lokal, Datenbank im Container):"); +log(` ${c.dim}1.${c.reset} docker compose up -d db`); +log(` ${c.dim}2.${c.reset} npm install`); +log(` ${c.dim}3.${c.reset} npm run db:migrate`); +log(` ${c.dim}4.${c.reset} npm run db:seed ${c.dim}(optional, Beispieldaten)${c.reset}`); +log(` ${c.dim}5.${c.reset} npm run dev ${c.dim}→ http://localhost:3000${c.reset}`); +log(""); +log("Alles in Docker:"); +log(` ${c.dim}1.${c.reset} docker compose --profile full up -d --build`); +log(` ${c.dim}2.${c.reset} ${c.dim}→ http://localhost:3000${c.reset}`); +log(""); +log("Eigene, bereits laufende PostgreSQL:"); +log(` DATABASE_URL in der .env anpassen, dann npm run db:migrate`); +log("");