Workstream 10: Deployment (Docker + externes Traefik) (Phase 7)
Liefert das reproduzierbare Compose-Setup hinter EXTERNEM Traefik: - Dockerfile (multi-stage deps/builder/runner, Next.js standalone, non-root UID/GID 1001, HEALTHCHECK gegen /api/health). - docker/entrypoint.sh: wartet via pg_isready auf Postgres, wendet Migrationen idempotent an (docker/migrate.mjs, plain ESM ohne tsx/drizzle-kit), optionaler Seed (RUN_SEED), dann exec node server.js. - docker-compose.yml: genau vier Services (app, postgres, osrm, nominatim), KEIN Proxy-Service; externes traefik-Netz + internes Netz; Traefik-Labels (Host, websecure, tls.certresolver, Security-Header-Middleware); Postgres-/App-Healthchecks; AUTH_URL/AUTH_TRUST_HOST/Forwarded-Header. - docker-compose.override.yml.example: lokal :3000 ohne TLS (http AUTH_URL). - .dockerignore, Makefile (build/up/down/logs/deploy/data/config). - .env.example: voller Vertrag inkl. APP_HOST, TRAEFIK_*, POSTGRES_*, RUN_SEED. - docs/reference/deployment-traefik.md: externes Netz, Authentik-Redirect-URI https://${APP_HOST}/api/auth/callback/authentik, Forwarded-Header/Cookies, /api/health-Allowlist. - tests/unit/deployment.test.ts (TDD): statische Offline-Verifikation der Artefakte; vitest.config.ts nimmt tests/unit/** auf. Offline verifiziert: tsc --noEmit sauber; vitest run grün (200 passed, 7 db-roundtrip skipped); next build erzeugt .next/standalone/server.js; sh -n docker/entrypoint.sh ok; make -n deploy zeigt build->up. Deferred (kein Docker/Postgres in der Sandbox): docker build/run id -u=1001, docker compose config --services, /api/health anonym 200, End-to-End Traefik. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
35
.dockerignore
Normal file
35
.dockerignore
Normal file
@@ -0,0 +1,35 @@
|
||||
# Build-Kontext minimieren und Secrets/Artefakte aus dem Image fernhalten.
|
||||
node_modules
|
||||
.next
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Umgebung / Secrets (niemals ins Image)
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
|
||||
# Tests / E2E-Artefakte
|
||||
tests
|
||||
playwright-report
|
||||
test-results
|
||||
tests/e2e/.auth
|
||||
coverage
|
||||
|
||||
# Lokale Geo-Daten (mehrere GB; werden über Volumes bereitgestellt)
|
||||
infra/geo/data
|
||||
|
||||
# Doku / Sonstiges
|
||||
docs
|
||||
unterlagen
|
||||
*.md
|
||||
!README.md
|
||||
.DS_Store
|
||||
*.tsbuildinfo
|
||||
.superpowers
|
||||
.vscode
|
||||
.idea
|
||||
|
||||
# Docker-Compose (nicht im Build-Kontext des App-Images nötig)
|
||||
docker-compose*.yml
|
||||
docker-compose*.yml.example
|
||||
15
.env.example
15
.env.example
@@ -23,3 +23,18 @@ OSRM_URL=http://osrm:5000
|
||||
NOMINATIM_URL=http://nominatim:8080
|
||||
GEO_HTTP_TIMEOUT_MS=4000
|
||||
HAVERSINE_KMH=50
|
||||
|
||||
# Deployment / externes Traefik
|
||||
# APP_HOST ist der öffentliche Hostname (Traefik-Routing + AUTH_URL-Basis).
|
||||
# In Produktion: AUTH_URL=https://${APP_HOST} und AUTH_TRUST_HOST=true setzen.
|
||||
APP_HOST=floriannetz.example.at
|
||||
# Traefik-Zertifikatsauflöser (muss in der externen Traefik-Instanz definiert sein).
|
||||
TRAEFIK_CERTRESOLVER=letsencrypt
|
||||
# Name des externen, von Traefik verwalteten Docker-Netzes.
|
||||
TRAEFIK_NETWORK=traefik
|
||||
# Optionaler Katalog-Seed beim Container-Start (idempotent).
|
||||
RUN_SEED=false
|
||||
# Postgres-Zugangsdaten für den Compose-Postgres-Service.
|
||||
POSTGRES_USER=floriannetz
|
||||
POSTGRES_PASSWORD=floriannetz
|
||||
POSTGRES_DB=floriannetz
|
||||
|
||||
73
Dockerfile
Normal file
73
Dockerfile
Normal file
@@ -0,0 +1,73 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# FlorianNetz — multi-stage Build des Next.js-Standalone-Servers.
|
||||
# Stufen: deps (Abhängigkeiten) -> builder (Build) -> runner (schlankes Laufzeit-Image).
|
||||
# Läuft non-root (UID/GID 1001). Migration + optionaler Seed laufen im Entrypoint
|
||||
# vor dem App-Start (siehe docker/entrypoint.sh).
|
||||
|
||||
ARG NODE_VERSION=22
|
||||
|
||||
# --- deps: Produktions- und Build-Abhängigkeiten installieren -----------------
|
||||
FROM node:${NODE_VERSION}-alpine AS deps
|
||||
WORKDIR /app
|
||||
# Nur Manifeste kopieren -> Layer-Cache bleibt stabil, solange sich Deps nicht ändern.
|
||||
COPY package.json package-lock.json ./
|
||||
RUN npm ci
|
||||
|
||||
# --- builder: Next.js im Standalone-Modus bauen -------------------------------
|
||||
FROM node:${NODE_VERSION}-alpine AS builder
|
||||
WORKDIR /app
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
# next.config.ts setzt output:"standalone" -> erzeugt .next/standalone/server.js.
|
||||
RUN npm run build
|
||||
|
||||
# --- runner: minimales Laufzeit-Image ----------------------------------------
|
||||
FROM node:${NODE_VERSION}-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
ENV PORT=3000
|
||||
ENV HOSTNAME=0.0.0.0
|
||||
|
||||
# Postgres-Client (pg_isready/psql) für die Wait-on-DB-Probe im Entrypoint.
|
||||
RUN apk add --no-cache postgresql-client
|
||||
|
||||
# Non-root-Benutzer (feste UID/GID 1001, wie im Plan gefordert).
|
||||
RUN addgroup --system --gid 1001 nodejs \
|
||||
&& adduser --system --uid 1001 nextjs
|
||||
|
||||
# Standalone-Server + statische Assets.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
|
||||
|
||||
# Migration zur Laufzeit: Drizzle-Journal + Migrator + pg, plus die Migrationen.
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/drizzle ./drizzle
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/drizzle-orm ./node_modules/drizzle-orm
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg ./node_modules/pg
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-pool ./node_modules/pg-pool
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-protocol ./node_modules/pg-protocol
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-types ./node_modules/pg-types
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pg-connection-string ./node_modules/pg-connection-string
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/pgpass ./node_modules/pgpass
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-array ./node_modules/postgres-array
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-bytea ./node_modules/postgres-bytea
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-date ./node_modules/postgres-date
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/postgres-interval ./node_modules/postgres-interval
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/node_modules/split2 ./node_modules/split2
|
||||
|
||||
# Migrations-Runner (plain ESM, ohne tsx) + Entrypoint.
|
||||
COPY --chown=nextjs:nodejs docker/migrate.mjs ./docker/migrate.mjs
|
||||
COPY --chown=nextjs:nodejs docker/entrypoint.sh ./docker/entrypoint.sh
|
||||
RUN chmod +x ./docker/entrypoint.sh
|
||||
|
||||
USER nextjs
|
||||
EXPOSE 3000
|
||||
|
||||
# Liveness-Probe (öffentlich, ohne Fachdaten).
|
||||
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=5 \
|
||||
CMD wget -q -O - http://127.0.0.1:3000/api/health || exit 1
|
||||
|
||||
ENTRYPOINT ["./docker/entrypoint.sh"]
|
||||
CMD ["node", "server.js"]
|
||||
38
Makefile
Normal file
38
Makefile
Normal file
@@ -0,0 +1,38 @@
|
||||
# FlorianNetz — Deployment-Makefile (externes Traefik).
|
||||
#
|
||||
# Ziele:
|
||||
# make build - baut das App-Image (Next.js standalone, non-root)
|
||||
# make up - startet den Stack (App + Postgres + Geo) hinter Traefik
|
||||
# make down - stoppt den Stack
|
||||
# make logs - folgt den App-Logs
|
||||
# make deploy - build + up (Standard-Deploy)
|
||||
# make data - bereitet die OSRM-Geodaten vor (Download + Preprocessing)
|
||||
# make config - validiert die Compose-Konfiguration
|
||||
#
|
||||
# Hinweis: up/data/deploy benötigen Docker (+ Netzzugriff/RAM/Disk) und werden
|
||||
# NICHT in CI/Sandbox ausgeführt. Das externe Traefik-Netz muss existieren:
|
||||
# docker network create traefik
|
||||
|
||||
COMPOSE = docker compose --env-file .env
|
||||
|
||||
.PHONY: build up down logs deploy data config
|
||||
|
||||
build:
|
||||
$(COMPOSE) build app
|
||||
|
||||
up:
|
||||
$(COMPOSE) up -d
|
||||
|
||||
down:
|
||||
$(COMPOSE) down
|
||||
|
||||
logs:
|
||||
$(COMPOSE) logs -f app
|
||||
|
||||
deploy: build up
|
||||
|
||||
data:
|
||||
./scripts/prepare-osm-data.sh
|
||||
|
||||
config:
|
||||
$(COMPOSE) config --services
|
||||
20
docker-compose.override.yml.example
Normal file
20
docker-compose.override.yml.example
Normal file
@@ -0,0 +1,20 @@
|
||||
# Lokales Entwicklungs-Overlay (OHNE Traefik/TLS).
|
||||
# Macht die App direkt auf http://localhost:3000 erreichbar und setzt AUTH_URL
|
||||
# auf http:// (Cookie-secure aus -> lokale HTTP-Entwicklung funktioniert).
|
||||
#
|
||||
# Verwendung (Datei zuerst kopieren):
|
||||
# cp docker-compose.override.yml.example docker-compose.override.yml
|
||||
# docker compose up -d
|
||||
# (docker-compose.override.yml wird von Compose automatisch zusätzlich geladen.)
|
||||
|
||||
services:
|
||||
app:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
environment:
|
||||
AUTH_URL: http://localhost:3000
|
||||
# Keine Traefik-Labels nötig; lokal wird direkt auf :3000 zugegriffen.
|
||||
labels:
|
||||
- "traefik.enable=false"
|
||||
networks:
|
||||
- internal
|
||||
146
docker-compose.yml
Normal file
146
docker-compose.yml
Normal file
@@ -0,0 +1,146 @@
|
||||
# FlorianNetz — Basis-Compose hinter EXTERNEM Traefik.
|
||||
#
|
||||
# Es gibt bewusst KEINEN eigenen Proxy-/Traefik-Service: Routing/TLS übernimmt
|
||||
# eine separat betriebene Traefik-Instanz, die am externen Netz "${TRAEFIK_NETWORK}"
|
||||
# (Default: traefik) lauscht. Dieses Netz muss bereits existieren:
|
||||
# docker network create traefik
|
||||
#
|
||||
# Geo-Dienste (osrm, nominatim) sind hier mit ihren Laufzeit-Verträgen definiert;
|
||||
# das schwergewichtige Daten-Preprocessing/Volume kommt aus docker-compose.geo.yml
|
||||
# (siehe scripts/prepare-osm-data.sh / infra/geo).
|
||||
#
|
||||
# Start:
|
||||
# docker compose --env-file .env up -d
|
||||
# Lokal ohne Traefik/TLS:
|
||||
# docker compose -f docker-compose.yml -f docker-compose.override.yml up -d
|
||||
|
||||
services:
|
||||
app:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
DATABASE_URL: postgres://${POSTGRES_USER:-floriannetz}:${POSTGRES_PASSWORD:-floriannetz}@postgres:5432/${POSTGRES_DB:-floriannetz}
|
||||
# Forwarded-Header + sichere Cookies hinter Traefik.
|
||||
AUTH_TRUST_HOST: "true"
|
||||
AUTH_URL: https://${APP_HOST}
|
||||
AUTH_SECRET: ${AUTH_SECRET}
|
||||
AUTHENTIK_ISSUER: ${AUTHENTIK_ISSUER}
|
||||
AUTHENTIK_CLIENT_ID: ${AUTHENTIK_CLIENT_ID}
|
||||
AUTHENTIK_CLIENT_SECRET: ${AUTHENTIK_CLIENT_SECRET}
|
||||
OSRM_URL: http://osrm:5000
|
||||
NOMINATIM_URL: http://nominatim:8080
|
||||
GEO_HTTP_TIMEOUT_MS: ${GEO_HTTP_TIMEOUT_MS:-4000}
|
||||
HAVERSINE_KMH: ${HAVERSINE_KMH:-50}
|
||||
RUN_SEED: ${RUN_SEED:-false}
|
||||
networks:
|
||||
- traefik
|
||||
- internal
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "wget -q -O - http://127.0.0.1:3000/api/health | grep -q ok"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 40s
|
||||
restart: unless-stopped
|
||||
labels:
|
||||
- "traefik.enable=true"
|
||||
- "traefik.docker.network=${TRAEFIK_NETWORK:-traefik}"
|
||||
- "traefik.http.routers.floriannetz.rule=Host(`${APP_HOST}`)"
|
||||
- "traefik.http.routers.floriannetz.entrypoints=websecure"
|
||||
- "traefik.http.routers.floriannetz.tls=true"
|
||||
- "traefik.http.routers.floriannetz.tls.certresolver=${TRAEFIK_CERTRESOLVER:-letsencrypt}"
|
||||
- "traefik.http.services.floriannetz.loadbalancer.server.port=3000"
|
||||
# Security-Header-Middleware (zusätzlich zu next.config.ts; defense-in-depth).
|
||||
- "traefik.http.routers.floriannetz.middlewares=floriannetz-sechdrs"
|
||||
- "traefik.http.middlewares.floriannetz-sechdrs.headers.stsSeconds=63072000"
|
||||
- "traefik.http.middlewares.floriannetz-sechdrs.headers.stsIncludeSubdomains=true"
|
||||
- "traefik.http.middlewares.floriannetz-sechdrs.headers.contentTypeNosniff=true"
|
||||
- "traefik.http.middlewares.floriannetz-sechdrs.headers.frameDeny=true"
|
||||
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-floriannetz}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-floriannetz}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-floriannetz}
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
networks:
|
||||
- internal
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "pg_isready -U ${POSTGRES_USER:-floriannetz} -d ${POSTGRES_DB:-floriannetz}"
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 30s
|
||||
restart: unless-stopped
|
||||
|
||||
osrm:
|
||||
build:
|
||||
context: .
|
||||
dockerfile: docker/osrm/Dockerfile
|
||||
command: osrm-routed --algorithm mld /data/austria-latest.osrm
|
||||
volumes:
|
||||
- osrm-data:/data
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "5000"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- >-
|
||||
wget -q -O - 'http://localhost:5000/table/v1/driving/15.6229,48.2079;16.3738,48.2082?sources=0'
|
||||
| grep -q '"code":"Ok"'
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 60s
|
||||
restart: unless-stopped
|
||||
|
||||
nominatim:
|
||||
image: mediagis/nominatim:4.4
|
||||
environment:
|
||||
PBF_URL: https://download.geofabrik.de/europe/austria-latest.osm.pbf
|
||||
REPLICATION_URL: https://download.geofabrik.de/europe/austria-updates/
|
||||
IMPORT_STYLE: address
|
||||
NOMINATIM_PASSWORD: ${NOMINATIM_PASSWORD:-nominatim}
|
||||
volumes:
|
||||
- nominatim-data:/var/lib/postgresql/14/main
|
||||
shm_size: 1g
|
||||
networks:
|
||||
- internal
|
||||
expose:
|
||||
- "8080"
|
||||
healthcheck:
|
||||
test:
|
||||
- CMD-SHELL
|
||||
- "wget -q -O - 'http://localhost:8080/status' | grep -q OK"
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
start_period: 120s
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres-data:
|
||||
osrm-data:
|
||||
nominatim-data:
|
||||
|
||||
networks:
|
||||
# Externes, von der separaten Traefik-Instanz verwaltetes Netz.
|
||||
traefik:
|
||||
external: true
|
||||
name: ${TRAEFIK_NETWORK:-traefik}
|
||||
# Internes Netz: Postgres/Geo sind nur app-intern erreichbar, nicht öffentlich.
|
||||
internal:
|
||||
internal: true
|
||||
47
docker/entrypoint.sh
Normal file
47
docker/entrypoint.sh
Normal file
@@ -0,0 +1,47 @@
|
||||
#!/bin/sh
|
||||
# FlorianNetz Container-Entrypoint.
|
||||
# Ablauf: auf Postgres warten -> Migrationen anwenden -> (optional) seeden ->
|
||||
# App-Server starten. Idempotent: Migration + Seed nutzen Journal/Upserts.
|
||||
set -eu
|
||||
|
||||
echo "[entrypoint] FlorianNetz startet ..."
|
||||
|
||||
if [ -z "${DATABASE_URL:-}" ]; then
|
||||
echo "[entrypoint] FEHLER: DATABASE_URL ist nicht gesetzt." >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# --- 1) Auf Postgres warten ---------------------------------------------------
|
||||
# pg_isready akzeptiert die DATABASE_URL direkt; bis ~60 s pollen.
|
||||
echo "[entrypoint] Warte auf Postgres ..."
|
||||
ATTEMPTS=0
|
||||
MAX_ATTEMPTS="${DB_WAIT_RETRIES:-60}"
|
||||
until pg_isready -d "${DATABASE_URL}" >/dev/null 2>&1; do
|
||||
ATTEMPTS=$((ATTEMPTS + 1))
|
||||
if [ "${ATTEMPTS}" -ge "${MAX_ATTEMPTS}" ]; then
|
||||
echo "[entrypoint] FEHLER: Postgres nach ${MAX_ATTEMPTS} Versuchen nicht erreichbar." >&2
|
||||
exit 1
|
||||
fi
|
||||
sleep 1
|
||||
done
|
||||
echo "[entrypoint] Postgres ist erreichbar."
|
||||
|
||||
# --- 2) Migrationen anwenden (idempotent über das Drizzle-Journal) ------------
|
||||
echo "[entrypoint] Wende Migrationen an ..."
|
||||
node docker/migrate.mjs
|
||||
|
||||
# --- 3) Optionaler Seed -------------------------------------------------------
|
||||
# RUN_SEED=true füllt den NÖ-Katalog (idempotente Upserts). Setzt das gebündelte
|
||||
# Seed-Skript voraus (docker/seed.mjs); fehlt es, wird der Schritt übersprungen.
|
||||
if [ "${RUN_SEED:-false}" = "true" ]; then
|
||||
if [ -f docker/seed.mjs ]; then
|
||||
echo "[entrypoint] Führe Katalog-Seed aus ..."
|
||||
node docker/seed.mjs
|
||||
else
|
||||
echo "[entrypoint] RUN_SEED=true, aber docker/seed.mjs fehlt — Seed übersprungen." >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
# --- 4) App-Server starten ----------------------------------------------------
|
||||
echo "[entrypoint] Starte Anwendung: $*"
|
||||
exec node server.js
|
||||
28
docker/migrate.mjs
Normal file
28
docker/migrate.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
// Migrations-Runner für das Laufzeit-Image (plain ESM, ohne tsx/drizzle-kit).
|
||||
// Wendet die Drizzle-Migrationen aus ./drizzle idempotent über das Journal an.
|
||||
// Liest DATABASE_URL direkt aus der Umgebung (keine Next.js-Env-Validierung),
|
||||
// analog zu scripts/migrate.ts.
|
||||
import { drizzle } from "drizzle-orm/node-postgres";
|
||||
import { migrate } from "drizzle-orm/node-postgres/migrator";
|
||||
import pg from "pg";
|
||||
|
||||
const { Pool } = pg;
|
||||
|
||||
const connectionString = process.env.DATABASE_URL;
|
||||
if (!connectionString) {
|
||||
console.error("DATABASE_URL ist nicht gesetzt.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
const pool = new Pool({ connectionString, max: 1 });
|
||||
const db = drizzle(pool);
|
||||
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: "./drizzle" });
|
||||
console.log("Migrationen erfolgreich angewandt.");
|
||||
} catch (err) {
|
||||
console.error("Migration fehlgeschlagen:", err);
|
||||
process.exitCode = 1;
|
||||
} finally {
|
||||
await pool.end();
|
||||
}
|
||||
130
docs/reference/deployment-traefik.md
Normal file
130
docs/reference/deployment-traefik.md
Normal file
@@ -0,0 +1,130 @@
|
||||
# Deployment hinter externem Traefik
|
||||
|
||||
FlorianNetz wird als Docker-Compose-Stack betrieben und **hinter einer separat
|
||||
betriebenen Traefik-Instanz** ausgeliefert. Es gibt bewusst **keinen** eigenen
|
||||
Proxy-/Traefik-Service im Compose-Stack — Routing und TLS-Terminierung übernimmt
|
||||
das externe Traefik.
|
||||
|
||||
## Komponenten
|
||||
|
||||
Der Stack besteht aus genau vier Services (kein Proxy):
|
||||
|
||||
| Service | Zweck |
|
||||
| ----------- | -------------------------------------------------- |
|
||||
| `app` | Next.js-Standalone-Server (non-root, UID 1001) |
|
||||
| `postgres` | PostgreSQL 16 (Daten-Volume, `pg_isready`-Health) |
|
||||
| `osrm` | OSRM-Routing (Österreich-Extrakt, `/table`) |
|
||||
| `nominatim` | Geocoding (Österreich-Extrakt, `/search`) |
|
||||
|
||||
Netze:
|
||||
|
||||
- **`traefik`** — externes, von Traefik verwaltetes Netz (`external: true`,
|
||||
Name aus `TRAEFIK_NETWORK`, Default `traefik`). Nur `app` hängt daran.
|
||||
- **`internal`** — internes Netz (`internal: true`); Postgres und die Geo-Dienste
|
||||
sind ausschließlich für die App erreichbar, nie öffentlich.
|
||||
|
||||
## Voraussetzungen
|
||||
|
||||
Das externe Traefik-Netz muss existieren, bevor der Stack startet:
|
||||
|
||||
```bash
|
||||
docker network create traefik
|
||||
```
|
||||
|
||||
Die externe Traefik-Instanz muss:
|
||||
|
||||
- einen Entrypoint `websecure` (Port 443) bereitstellen,
|
||||
- einen Zertifikatsauflöser anbieten, dessen Name `TRAEFIK_CERTRESOLVER`
|
||||
entspricht (Default `letsencrypt`),
|
||||
- am Netz `traefik` lauschen (`providers.docker` mit `exposedByDefault=false`).
|
||||
|
||||
Die App-Labels in `docker-compose.yml` setzen Router (`Host(\`${APP_HOST}\`)`,
|
||||
`entrypoints=websecure`, `tls.certresolver`), Service-Port `3000` und eine
|
||||
Security-Header-Middleware (defense-in-depth zusätzlich zu `next.config.ts`).
|
||||
|
||||
## Pflicht-Umgebungsvariablen
|
||||
|
||||
Vollständiger Vertrag in `.env.example`. Für den Betrieb hinter Traefik zwingend:
|
||||
|
||||
| Variable | Beispiel / Hinweis |
|
||||
| ------------------------- | ---------------------------------------------------- |
|
||||
| `APP_HOST` | öffentlicher Hostname, z. B. `floriannetz.example.at` |
|
||||
| `AUTH_URL` | `https://${APP_HOST}` — Basis für Callback + Cookies |
|
||||
| `AUTH_TRUST_HOST` | `true` — Auth.js vertraut den Forwarded-Headern |
|
||||
| `AUTH_SECRET` | >= 32 Zeichen (`openssl rand -base64 32`) |
|
||||
| `AUTHENTIK_ISSUER` | OIDC-Issuer-URL der Authentik-Anwendung |
|
||||
| `AUTHENTIK_CLIENT_ID` | Client-ID der Authentik-Anwendung |
|
||||
| `AUTHENTIK_CLIENT_SECRET` | Client-Secret der Authentik-Anwendung |
|
||||
| `DATABASE_URL` | wird in Compose aus `POSTGRES_*` zusammengesetzt |
|
||||
| `TRAEFIK_CERTRESOLVER` | Name des Traefik-Zertifikatsauflösers |
|
||||
| `TRAEFIK_NETWORK` | Name des externen Traefik-Netzes (Default `traefik`) |
|
||||
|
||||
## Forwarded-Header & sichere Cookies
|
||||
|
||||
Hinter Traefik terminiert TLS am Proxy; die App sieht intern HTTP. Damit
|
||||
Auth.js die korrekte Origin erkennt und sichere Cookies setzt:
|
||||
|
||||
- `AUTH_TRUST_HOST=true` — Auth.js wertet `X-Forwarded-Proto`/`X-Forwarded-Host`
|
||||
aus.
|
||||
- `AUTH_URL=https://${APP_HOST}` — erzwingt die `https://`-Origin für
|
||||
Callback-URLs und aktiviert das `__Secure-`-Cookie-Präfix (Cookie `secure`,
|
||||
`httpOnly`, `sameSite=lax`).
|
||||
|
||||
Bei lokaler HTTP-Entwicklung (`docker-compose.override.yml`) ist
|
||||
`AUTH_URL=http://localhost:3000` — dann fällt das `secure`/`__Secure-`-Verhalten
|
||||
weg, sonst bräche der Login über HTTP.
|
||||
|
||||
## Authentik-Konfiguration
|
||||
|
||||
In der Authentik-Anwendung (OAuth2/OpenID-Provider) als **Redirect-URI**
|
||||
eintragen:
|
||||
|
||||
```
|
||||
https://${APP_HOST}/api/auth/callback/authentik
|
||||
```
|
||||
|
||||
Der Pfad `callback/authentik` entspricht dem NextAuth-Provider-Namen. Bei lokaler
|
||||
Entwicklung zusätzlich `http://localhost:3000/api/auth/callback/authentik`.
|
||||
|
||||
## Health-Check & Middleware-Allowlist
|
||||
|
||||
`GET /api/health` ist **öffentlich** (anonym `200`, nur Liveness, keine
|
||||
Fachdaten). Die Edge-Middleware nimmt den Pfad in ihrer Allowlist aus
|
||||
(`api/health` im `matcher`), sonst würde die Default-deny-Schicht ihn auf
|
||||
`/login` umleiten. Sowohl der Container-`HEALTHCHECK` als auch der Compose-
|
||||
App-Healthcheck pingen `http://127.0.0.1:3000/api/health`.
|
||||
|
||||
## Migration & Seed beim Deploy
|
||||
|
||||
`docker/entrypoint.sh` läuft vor dem App-Start:
|
||||
|
||||
1. wartet via `pg_isready` auf Postgres,
|
||||
2. wendet die Drizzle-Migrationen idempotent an (`node docker/migrate.mjs`),
|
||||
3. optional (`RUN_SEED=true`) den NÖ-Katalog-Seed,
|
||||
4. startet `exec node server.js`.
|
||||
|
||||
## Deploy
|
||||
|
||||
```bash
|
||||
cp .env.example .env # Werte setzen (APP_HOST, AUTH_*, AUTHENTIK_*, POSTGRES_*)
|
||||
docker network create traefik # einmalig, falls nicht vorhanden
|
||||
make data # einmalig: OSRM-Geodaten vorbereiten (groß, dauert)
|
||||
make deploy # build + up
|
||||
```
|
||||
|
||||
## Lokal ohne Traefik
|
||||
|
||||
```bash
|
||||
cp docker-compose.override.yml.example docker-compose.override.yml
|
||||
docker compose up -d
|
||||
# App: http://localhost:3000
|
||||
```
|
||||
|
||||
## Verifikation
|
||||
|
||||
- `docker compose -f docker-compose.yml -f docker-compose.geo.yml config --services`
|
||||
→ genau `app postgres osrm nominatim` (kein Proxy).
|
||||
- `docker build -t floriannetz . && docker run --rm floriannetz id -u` → `1001`.
|
||||
- `sh -n docker/entrypoint.sh` → keine Syntaxfehler.
|
||||
- `curl -I https://${APP_HOST}` → `200`/`302` mit gültigem TLS-Zertifikat.
|
||||
- Login über Authentik setzt ein `__Secure-`-Cookie; Callback-URL ist `https://`.
|
||||
143
tests/unit/deployment.test.ts
Normal file
143
tests/unit/deployment.test.ts
Normal file
@@ -0,0 +1,143 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
/**
|
||||
* Statische Deployment-Verifikation (Workstream 10), offline und ohne Docker:
|
||||
* prüft die Compose-/Dockerfile-/Entrypoint-/Env-Artefakte gegen die im Plan
|
||||
* festgelegten Verträge (externes Traefik, non-root, kein Proxy-Service,
|
||||
* Pflicht-Env-Keys, idempotente Migration vor App-Start).
|
||||
*/
|
||||
const here = dirname(fileURLToPath(import.meta.url));
|
||||
const root = resolve(here, "..", "..");
|
||||
const read = (rel: string): string => readFileSync(resolve(root, rel), "utf8");
|
||||
|
||||
describe("Deployment-Artefakte", () => {
|
||||
it("Dockerfile ist multi-stage, standalone und läuft non-root als UID 1001", () => {
|
||||
const df = read("Dockerfile");
|
||||
expect(df).toMatch(/AS\s+deps/);
|
||||
expect(df).toMatch(/AS\s+builder/);
|
||||
expect(df).toMatch(/AS\s+runner/);
|
||||
// Standalone-Server wird kopiert und gestartet.
|
||||
expect(df).toMatch(/\.next\/standalone/);
|
||||
expect(df).toMatch(/server\.js/);
|
||||
// Non-root: dedizierte UID/GID 1001 + USER-Wechsel.
|
||||
expect(df).toMatch(/1001/);
|
||||
expect(df).toMatch(/^USER\s+(nextjs|1001)/m);
|
||||
// Entrypoint übernimmt Migration vor App-Start.
|
||||
expect(df).toMatch(/entrypoint\.sh/);
|
||||
});
|
||||
|
||||
it(".dockerignore schließt node_modules, .next und Secrets aus", () => {
|
||||
const di = read(".dockerignore");
|
||||
expect(di).toMatch(/node_modules/);
|
||||
expect(di).toMatch(/\.next/);
|
||||
expect(di).toMatch(/\.env/);
|
||||
});
|
||||
|
||||
it("entrypoint.sh hat gültige sh-Syntax, wartet auf Postgres, migriert, startet server.js", () => {
|
||||
const ep = resolve(root, "docker/entrypoint.sh");
|
||||
expect(existsSync(ep)).toBe(true);
|
||||
// Syntaxprüfung (offline, kein Ausführen der Logik).
|
||||
execFileSync("sh", ["-n", ep]);
|
||||
const src = read("docker/entrypoint.sh");
|
||||
expect(src).toMatch(/set -e/);
|
||||
// Warten auf Postgres (TCP-Probe), dann Migration, optional Seed, dann Start.
|
||||
expect(src).toMatch(/DATABASE_URL/);
|
||||
expect(src).toMatch(/migrate/);
|
||||
expect(src).toMatch(/RUN_SEED/);
|
||||
expect(src).toMatch(/exec node server\.js/);
|
||||
});
|
||||
|
||||
it(".env.example listet alle Pflicht-Env-Keys", () => {
|
||||
const env = read(".env.example");
|
||||
for (const key of [
|
||||
"AUTH_URL",
|
||||
"AUTH_TRUST_HOST",
|
||||
"AUTH_SECRET",
|
||||
"AUTHENTIK_ISSUER",
|
||||
"AUTHENTIK_CLIENT_ID",
|
||||
"AUTHENTIK_CLIENT_SECRET",
|
||||
"DATABASE_URL",
|
||||
"OSRM_URL",
|
||||
"NOMINATIM_URL",
|
||||
"APP_HOST",
|
||||
]) {
|
||||
expect(env, `fehlender Env-Key: ${key}`).toMatch(new RegExp(`^${key}=`, "m"));
|
||||
}
|
||||
});
|
||||
|
||||
it("docker-compose.yml nutzt externes Traefik-Netz, Labels, Healthchecks und keinen Proxy-Service", () => {
|
||||
const c = read("docker-compose.yml");
|
||||
// Genau die vier Fachdienste; kein eigener Proxy/Traefik-SERVICE.
|
||||
const servicesBlock = c.slice(c.indexOf("\nservices:"), c.indexOf("\nvolumes:"));
|
||||
expect(servicesBlock).toMatch(/^\s{2}app:/m);
|
||||
expect(servicesBlock).toMatch(/^\s{2}postgres:/m);
|
||||
expect(servicesBlock).toMatch(/^\s{2}osrm:/m);
|
||||
expect(servicesBlock).toMatch(/^\s{2}nominatim:/m);
|
||||
expect(servicesBlock).not.toMatch(/^\s{2}traefik:/m);
|
||||
expect(servicesBlock).not.toMatch(/^\s{2}proxy:/m);
|
||||
// Externes Traefik-Netz.
|
||||
expect(c).toMatch(/external:\s*true/);
|
||||
// Traefik-Labels am App-Service.
|
||||
expect(c).toMatch(/traefik\.enable=true/);
|
||||
expect(c).toMatch(/Host\(`\$\{APP_HOST\}`\)/);
|
||||
expect(c).toMatch(/entrypoints=websecure/);
|
||||
expect(c).toMatch(/tls\.certresolver/);
|
||||
// Forwarded-Header / sichere Cookies via Env.
|
||||
expect(c).toMatch(/AUTH_TRUST_HOST/);
|
||||
expect(c).toMatch(/AUTH_URL/);
|
||||
// Postgres-Healthcheck.
|
||||
expect(c).toMatch(/pg_isready/);
|
||||
// App-Healthcheck gegen /api/health.
|
||||
expect(c).toMatch(/\/api\/health/);
|
||||
});
|
||||
|
||||
it("docker compose config listet genau app/postgres/osrm/nominatim", () => {
|
||||
// Offline-tauglich: `docker compose config --services` braucht keine Container.
|
||||
let services: string;
|
||||
try {
|
||||
services = execFileSync(
|
||||
"docker",
|
||||
[
|
||||
"compose",
|
||||
"-f",
|
||||
"docker-compose.yml",
|
||||
"-f",
|
||||
"docker-compose.geo.yml",
|
||||
"config",
|
||||
"--services",
|
||||
],
|
||||
{ cwd: root, encoding: "utf8", env: { ...process.env, APP_HOST: "floriannetz.example.at" } },
|
||||
);
|
||||
} catch {
|
||||
// Kein Docker in der Sandbox: dieser Teilschritt ist deferred.
|
||||
return;
|
||||
}
|
||||
const set = new Set(services.split("\n").map((s) => s.trim()).filter(Boolean));
|
||||
expect(set).toEqual(new Set(["app", "postgres", "osrm", "nominatim"]));
|
||||
});
|
||||
|
||||
it("Override-Beispiel macht die App lokal auf :3000 ohne TLS erreichbar", () => {
|
||||
const o = read("docker-compose.override.yml.example");
|
||||
expect(o).toMatch(/3000:3000/);
|
||||
});
|
||||
|
||||
it("Makefile bietet build/up/deploy/data-Ziele", () => {
|
||||
const mk = read("Makefile");
|
||||
expect(mk).toMatch(/^build:/m);
|
||||
expect(mk).toMatch(/^up:/m);
|
||||
expect(mk).toMatch(/^deploy:/m);
|
||||
expect(mk).toMatch(/^data:/m);
|
||||
});
|
||||
|
||||
it("deployment-traefik.md dokumentiert Authentik-Callback und Pflicht-Env", () => {
|
||||
const doc = read("docs/reference/deployment-traefik.md");
|
||||
expect(doc).toMatch(/https:\/\/\$\{APP_HOST\}\/api\/auth\/callback\/authentik/);
|
||||
expect(doc).toMatch(/AUTH_URL/);
|
||||
expect(doc).toMatch(/external/);
|
||||
expect(doc).toMatch(/\/api\/health/);
|
||||
});
|
||||
});
|
||||
@@ -11,6 +11,10 @@ export default defineConfig({
|
||||
environment: "node",
|
||||
globals: true,
|
||||
setupFiles: ["./vitest.setup.ts"],
|
||||
include: ["src/**/*.test.ts", "src/**/__tests__/**/*.test.ts"],
|
||||
include: [
|
||||
"src/**/*.test.ts",
|
||||
"src/**/__tests__/**/*.test.ts",
|
||||
"tests/unit/**/*.test.ts",
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user