Betrieb: Dockge-Stack, Images, Prüfung

compose.yaml aus docs/11-betrieb.md, ergänzt um Healthchecks für db-backup und
offsite-backup. Das Dokument verlangt für jeden Dienst einen, die abgedruckte
Fassung hatte für die beiden keinen, und ohne ihn zeigt Dockge nur "läuft",
auch wenn seit Wochen nichts mehr gesichert wird.

Die Migration läuft im Startkommando von web, nicht in einem Init-Container.
Die Prisma-CLI samt Schema-Engine liegt dafür unter /app/migrator im Image:
ein Container, der nur startet, wenn gerade eine Registry erreichbar ist, wäre
auf einem Homeserver die falsche Bauart.

Alle drei Images gebaut und laufen gegen eine echte Postgres-Instanz geprüft:
web wendet Migrationen an und liefert /api/health aus, worker schreibt sein
Lebenszeichen, pdf rendert ein echtes PDF.

Die CI prüft Lint, Typen, Tests und E2E gegen den Standalone-Server, also so,
wie der Container startet, und hält das Bündel-Budget aus
docs/09-performance-und-ux.md ein.

Phase 0 aus TASKS.md ist damit abgehakt.
This commit is contained in:
Harlekin
2026-09-01 00:02:00 +02:00
parent 8375d1cb03
commit f77a589dda
8 changed files with 647 additions and 0 deletions
+49
View File
@@ -0,0 +1,49 @@
# AfiS-Aktensystem — Vorlage für den .env-Reiter in Dockge.
# Diese Datei enthält nur Platzhalter. Die echten Werte gehören nach
# /opt/stacks/afis/.env und niemals ins Repository. Siehe docs/11-betrieb.md.
# --- Zeitzone -----------------------------------------------------------------
TZ=Europe/Berlin
# --- Datenbank ----------------------------------------------------------------
# Wird von db, pgbouncer, web, worker und den Backups gelesen.
POSTGRES_PASSWORD=hier-ein-langes-zufaelliges-passwort
# Die Anwendung spricht über pgbouncer im Transaktionsmodus, deshalb pgbouncer=true.
DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@pgbouncer:6432/afis?pgbouncer=true&connection_limit=1
# Migrationen und LISTEN/NOTIFY brauchen eine Verbindung an pgbouncer vorbei.
DIRECT_DATABASE_URL=postgresql://afis:hier-ein-langes-zufaelliges-passwort@db:5432/afis
# --- Anwendung ----------------------------------------------------------------
APP_URL=https://afis.example.de
# 32+ Zeichen. Erzeugen mit: openssl rand -base64 48
AUTH_SECRET=hier-ein-langes-zufaelliges-geheimnis-mit-mindestens-32-zeichen
# --- Discord ------------------------------------------------------------------
# Aus dem Developer Portal. Das Server Members Intent muss aktiviert sein,
# sonst liefert der Bot keine Rollen. Siehe docs/03-auth-und-rechte.md.
DISCORD_CLIENT_ID=
DISCORD_CLIENT_SECRET=
DISCORD_BOT_TOKEN=
DISCORD_GUILD_ID=
# Kanal, in den der Worker Start, Verbindungsabbruch und fehlgeschlagenen
# Voll-Abgleich meldet. Das ist die eigentliche Alarmierung.
DISCORD_ADMIN_CHANNEL_ID=
# --- Notfallzugang ------------------------------------------------------------
# Eine oder zwei Discord-IDs, kommagetrennt. Diese Nutzer haben immer Stufe 5
# und alle Rechte, unabhängig vom Rollen-Mapping. Ohne das sperrt man sich beim
# ersten Fehler in der Rollenverwaltung selbst aus.
BOOTSTRAP_ADMIN_IDS=
# --- Dateien und Prüfkette ----------------------------------------------------
FILES_DIR=/data/files
CLAMAV_SOCKET=/var/run/clamav/clamd.sock
PDF_SERVICE_URL=http://pdf:3000
# --- Cloudflare Tunnel --------------------------------------------------------
CLOUDFLARE_TUNNEL_TOKEN=
# --- Auslagerungs-Backup ------------------------------------------------------
RESTIC_REPOSITORY=
RESTIC_PASSWORD=
+108
View File
@@ -0,0 +1,108 @@
name: Prüfung
on:
push:
branches: [main]
pull_request:
concurrency:
group: ci-${{ github.ref }}
cancel-in-progress: true
jobs:
pruefen:
name: Lint, Typen, Tests
runs-on: ubuntu-latest
services:
postgres:
image: postgres:16-alpine
env:
POSTGRES_DB: afis
POSTGRES_USER: afis
POSTGRES_PASSWORD: afis
ports: ['5432:5432']
options: >-
--health-cmd "pg_isready -U afis -d afis"
--health-interval 10s --health-timeout 5s --health-retries 5
env:
DATABASE_URL: postgresql://afis:afis@localhost:5432/afis
DIRECT_DATABASE_URL: postgresql://afis:afis@localhost:5432/afis
APP_URL: http://127.0.0.1:3100
AUTH_SECRET: nur-fuer-die-pruefung-mindestens-32-zeichen-lang
DISCORD_CLIENT_ID: pruefung
DISCORD_CLIENT_SECRET: pruefung
DISCORD_BOT_TOKEN: pruefung
DISCORD_GUILD_ID: '0'
NEXT_TELEMETRY_DISABLED: '1'
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: pnpm
- run: pnpm install --frozen-lockfile
# Erzeugt den Prisma-Client nach packages/db/src/generated und übersetzt
# @afis/db. Muss vor lint, typecheck und test laufen.
- name: Datenbankpaket bauen
run: pnpm --filter @afis/db run build
- name: Datenbank aufbauen
run: pnpm db:deploy
- run: pnpm db:seed
- run: pnpm lint
- run: pnpm typecheck
# Die Regeltests in packages/db laufen gegen die echte Postgres-Instanz.
- run: pnpm test
- name: Playwright-Browser
run: pnpm --filter @afis/web exec playwright install --with-deps chromium
# Baut die Anwendung und prüft sie über den Standalone-Server, also genau
# so, wie der Container sie startet.
- run: pnpm test:e2e
- uses: actions/upload-artifact@v4
if: failure()
with:
name: playwright-report
path: web/playwright-report/
retention-days: 7
# Budget aus docs/09-performance-und-ux.md: erste Route unter 120 kB
# JavaScript nach Kompression.
- name: Bündelgröße prüfen
run: pnpm --filter @afis/web exec node ../scripts/check-bundle-budget.mjs
images:
name: Images bauen
runs-on: ubuntu-latest
needs: pruefen
if: github.event_name == 'pull_request'
strategy:
fail-fast: false
matrix:
dienst: [web, worker, pdf]
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
# Auf Pull Requests nur bauen, nicht schieben. Das Schieben macht
# .github/workflows/images.yml auf main.
- uses: docker/build-push-action@v6
with:
context: .
file: docker/${{ matrix.dienst }}.Dockerfile
push: false
cache-from: type=gha,scope=${{ matrix.dienst }}
cache-to: type=gha,mode=max,scope=${{ matrix.dienst }}
+62
View File
@@ -0,0 +1,62 @@
name: Images
# Baut die drei Images und schiebt sie in die GitHub Container Registry.
# Dockge zieht sie beim Update, siehe docs/11-betrieb.md.
on:
push:
branches: [main]
tags: ['v*']
workflow_dispatch:
env:
REGISTRY: ghcr.io
jobs:
bauen:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
strategy:
fail-fast: false
matrix:
include:
- dienst: web
dockerfile: docker/web.Dockerfile
- dienst: worker
dockerfile: docker/worker.Dockerfile
- dienst: pdf
dockerfile: docker/pdf.Dockerfile
steps:
- uses: actions/checkout@v4
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: meta
uses: docker/metadata-action@v5
with:
images: ${{ env.REGISTRY }}/${{ github.repository_owner }}/afis-${{ matrix.dienst }}
tags: |
type=ref,event=branch
type=semver,pattern={{version}}
type=sha,format=short
type=raw,value=latest,enable={{is_default_branch}}
- uses: docker/build-push-action@v6
with:
context: .
file: ${{ matrix.dockerfile }}
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha,scope=${{ matrix.dienst }}
cache-to: type=gha,mode=max,scope=${{ matrix.dienst }}
+200
View File
@@ -0,0 +1,200 @@
# AfiS-Aktensystem — ein einziger Dockge-Stack.
# Ablage: /opt/stacks/afis/compose.yaml, .env im eigenen Dockge-Reiter.
# Begründung der Entscheidungen in docs/11-betrieb.md:
# - keine Einmal- und keine Init-Container, Dockge zeigt beendete Container nicht
# - Migrationen laufen im Startkommando von `web`
# - jeder Dienst hat einen Healthcheck, das ist in Dockge die Statusanzeige
# - kein Reverse Proxy: TLS und Kompression macht der Cloudflare Tunnel
#
# DEIN-USER durch den eigenen GitHub-Namen ersetzen.
x-logging: &logging
logging:
driver: json-file
options: { max-size: '10m', max-file: '3' }
services:
web:
<<: *logging
image: ghcr.io/DEIN-USER/afis-web:latest
restart: unless-stopped
# Die Migration läuft im Startkommando, nicht in einem Init-Container.
# Die Prisma-CLI liegt im Image unter /app/migrator, es wird nichts nachgeladen.
command: sh -c "cd /app/migrator && node node_modules/prisma/build/index.js migrate deploy && cd /app && node web/server.js"
env_file: .env
environment:
NODE_ENV: production
PORT: '3000'
HOSTNAME: '0.0.0.0'
volumes:
- files:/data/files
- clamav_socket:/var/run/clamav
ports:
- '3000:3000' # nur im LAN, von außen kommt der Tunnel
depends_on:
pgbouncer: { condition: service_healthy }
clamav: { condition: service_healthy }
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/api/health']
interval: 30s
timeout: 5s
retries: 3
start_period: 40s
worker:
<<: *logging
image: ghcr.io/DEIN-USER/afis-worker:latest
restart: unless-stopped
env_file: .env
depends_on:
pgbouncer: { condition: service_healthy }
healthcheck:
test: ['CMD', 'node', 'healthcheck.js'] # prüft Gateway-Verbindung
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
db:
<<: *logging
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: afis
POSTGRES_USER: afis
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
TZ: ${TZ}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ['CMD-SHELL', 'pg_isready -U afis -d afis']
interval: 10s
timeout: 5s
retries: 5
pgbouncer:
<<: *logging
image: edoburu/pgbouncer:latest
restart: unless-stopped
environment:
DB_HOST: db
DB_USER: afis
DB_PASSWORD: ${POSTGRES_PASSWORD}
DB_NAME: afis
POOL_MODE: transaction
MAX_CLIENT_CONN: '100'
DEFAULT_POOL_SIZE: '20'
AUTH_TYPE: scram-sha-256
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ['CMD-SHELL', 'pg_isready -h 127.0.0.1 -p 6432 -U afis']
interval: 15s
timeout: 5s
retries: 5
clamav:
<<: *logging
image: clamav/clamav:stable
restart: unless-stopped
environment:
CLAMAV_NO_MILTERD: 'true'
FRESHCLAM_CHECKS: '2'
volumes:
- clamav_db:/var/lib/clamav
- clamav_socket:/var/run/clamav
- files:/data/files
healthcheck:
test: ['CMD-SHELL', 'clamdscan --ping 1 || exit 1']
interval: 60s
timeout: 15s
retries: 3
start_period: 300s # erster Signaturdownload dauert
pdf:
<<: *logging
image: ghcr.io/DEIN-USER/afis-pdf:latest # Playwright-Basis
restart: unless-stopped
environment:
PORT: '3000'
APP_URL: ${APP_URL}
healthcheck:
test: ['CMD', 'wget', '-qO-', 'http://localhost:3000/health']
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
cloudflared:
<<: *logging
image: cloudflare/cloudflared:latest
restart: unless-stopped
command: tunnel --no-autoupdate run
environment:
TUNNEL_TOKEN: ${CLOUDFLARE_TUNNEL_TOKEN}
depends_on:
web: { condition: service_healthy }
healthcheck:
test: ['CMD', 'cloudflared', '--version']
interval: 60s
timeout: 10s
retries: 3
start_period: 20s
db-backup:
<<: *logging
image: prodrigestivill/postgres-backup-local:16
restart: unless-stopped
environment:
POSTGRES_HOST: db
POSTGRES_DB: afis
POSTGRES_USER: afis
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
POSTGRES_EXTRA_OPTS: '-Fc'
SCHEDULE: '0 15 4 * * *' # täglich 04:15
BACKUP_KEEP_DAYS: '7'
BACKUP_KEEP_WEEKS: '4'
BACKUP_KEEP_MONTHS: '6'
HEALTHCHECK_PORT: '8080'
TZ: ${TZ}
volumes:
- /mnt/backup/afis/db:/backups
depends_on:
db: { condition: service_healthy }
healthcheck:
test: ['CMD-SHELL', 'wget -qO- http://localhost:8080/ || exit 1']
interval: 60s
timeout: 10s
retries: 3
start_period: 30s
offsite-backup:
<<: *logging
image: mazzolino/restic:latest
restart: unless-stopped
environment:
RUN_ON_STARTUP: 'false'
BACKUP_CRON: '0 30 4 * * *' # 15 Minuten nach dem Dump
RESTIC_REPOSITORY: ${RESTIC_REPOSITORY}
RESTIC_PASSWORD: ${RESTIC_PASSWORD}
RESTIC_BACKUP_SOURCES: /data/db /data/files
RESTIC_FORGET_ARGS: '--keep-daily 7 --keep-weekly 4 --keep-monthly 6 --prune'
TZ: ${TZ}
volumes:
- /mnt/backup/afis/db:/data/db:ro
- files:/data/files:ro
# Kein HTTP-Endpunkt im Image. Geprüft wird, dass der Cron-Dauerläufer noch
# steht und restic aufrufbar ist. Ohne Healthcheck wäre der Dienst in Dockge
# nur "läuft", auch wenn er seit Wochen nichts mehr sichert.
healthcheck:
test: ['CMD-SHELL', 'pgrep -f go-crond >/dev/null && restic version >/dev/null || exit 1']
interval: 300s
timeout: 15s
retries: 3
start_period: 30s
volumes:
pgdata:
files:
clamav_db:
clamav_socket:
+39
View File
@@ -0,0 +1,39 @@
# Image für den Dienst `pdf`. Playwright-Basis mit vorinstalliertem Chromium,
# rendert die Druckansicht der Akte. Nur im Stack-Netz erreichbar.
ARG PLAYWRIGHT_VERSION=v1.62.1-noble
ARG PNPM_VERSION=9.15.4
FROM mcr.microsoft.com/playwright:${PLAYWRIGHT_VERSION} AS build
ARG PNPM_VERSION
RUN npm install -g pnpm@${PNPM_VERSION}
WORKDIR /repo
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc tsconfig.base.json ./
COPY packages/db/package.json packages/db/
COPY web/package.json web/
COPY worker/package.json worker/
COPY pdf/package.json pdf/
RUN pnpm install --frozen-lockfile --filter @afis/pdf...
COPY pdf/ pdf/
RUN pnpm --filter @afis/pdf build && \
pnpm --filter @afis/pdf --prod --no-optional deploy /bundle && \
cp -r pdf/dist /bundle/dist
FROM mcr.microsoft.com/playwright:${PLAYWRIGHT_VERSION} AS runtime
WORKDIR /app
ENV NODE_ENV=production \
PORT=3000
COPY --from=build --chown=pwuser:pwuser /bundle ./
USER pwuser
EXPOSE 3000
HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \
CMD wget -qO- http://localhost:3000/health || exit 1
CMD ["node", "dist/index.js"]
+81
View File
@@ -0,0 +1,81 @@
# Image für den Dienst `web`. Standalone-Build, siehe web/next.config.ts.
#
# Die Migration läuft im Startkommando des Containers, nicht hier und nicht in
# einem Init-Container: Dockge zeigt beendete Container nicht dauerhaft an.
# Begründung in docs/11-betrieb.md.
#
# Dafür liegt eine eigenständige Prisma-CLI unter /app/migrator im Image. Sie
# beim Start aus dem Netz zu ziehen wäre der falsche Weg: der Container startet
# dann nur, wenn die Registry gerade erreichbar ist.
ARG NODE_VERSION=22-alpine
ARG PNPM_VERSION=9.15.4
ARG PRISMA_VERSION=7.10.0
# --- Abhängigkeiten -----------------------------------------------------------
FROM node:${NODE_VERSION} AS deps
ARG PNPM_VERSION
RUN npm install -g pnpm@${PNPM_VERSION}
WORKDIR /repo
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc ./
COPY packages/db/package.json packages/db/
COPY web/package.json web/
COPY worker/package.json worker/
COPY pdf/package.json pdf/
RUN pnpm install --frozen-lockfile --filter @afis/web... --filter @afis/db...
# --- Bauen --------------------------------------------------------------------
FROM node:${NODE_VERSION} AS build
ARG PRISMA_VERSION
ARG PNPM_VERSION
RUN npm install -g pnpm@${PNPM_VERSION}
WORKDIR /repo
COPY --from=deps /repo/node_modules ./node_modules
COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules
COPY --from=deps /repo/web/node_modules ./web/node_modules
COPY . .
# Prisma-Client erzeugen und @afis/db nach dist übersetzen.
RUN pnpm --filter @afis/db build
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm --filter @afis/web build
# Eigenständige Migrations-CLI mit heruntergeladener Schema-Engine.
RUN mkdir -p /migrator && cd /migrator && \
npm install --no-package-lock --no-audit --no-fund "prisma@${PRISMA_VERSION}" && \
cp -r /repo/packages/db/prisma /migrator/prisma && \
cp /repo/packages/db/prisma.config.ts /migrator/prisma.config.ts
# --- Laufzeit -----------------------------------------------------------------
FROM node:${NODE_VERSION} AS runtime
WORKDIR /app
ENV NODE_ENV=production \
NEXT_TELEMETRY_DISABLED=1 \
PORT=3000 \
HOSTNAME=0.0.0.0
RUN apk add --no-cache wget openssl && \
addgroup -g 1001 -S afis && \
adduser -u 1001 -S afis -G afis
# Standalone-Ausgabe: server.js plus die tatsächlich benutzten node_modules.
COPY --from=build --chown=afis:afis /repo/web/.next/standalone/ ./
COPY --from=build --chown=afis:afis /repo/web/.next/static ./web/.next/static
COPY --from=build --chown=afis:afis /repo/web/public ./web/public
# Migrations-CLI samt Schema, Migrationsordner und Engine.
COPY --from=build --chown=afis:afis /migrator ./migrator
USER afis
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD wget -qO- http://localhost:3000/api/health || exit 1
# Das Startkommando setzt der Stack (Migration + Server), siehe compose.yaml.
CMD ["node", "web/server.js"]
+51
View File
@@ -0,0 +1,51 @@
# Image für den Dienst `worker`. Hängt am Discord-Gateway und hält die
# Guild-Mitgliedschaft in Postgres aktuell, siehe docs/02-architektur.md.
ARG NODE_VERSION=22-alpine
ARG PNPM_VERSION=9.15.4
FROM node:${NODE_VERSION} AS deps
ARG PNPM_VERSION
RUN npm install -g pnpm@${PNPM_VERSION}
WORKDIR /repo
COPY pnpm-workspace.yaml package.json pnpm-lock.yaml .npmrc ./
COPY packages/db/package.json packages/db/
COPY web/package.json web/
COPY worker/package.json worker/
COPY pdf/package.json pdf/
RUN pnpm install --frozen-lockfile --filter @afis/worker... --filter @afis/db...
FROM node:${NODE_VERSION} AS build
ARG PNPM_VERSION
RUN npm install -g pnpm@${PNPM_VERSION}
WORKDIR /repo
COPY --from=deps /repo/node_modules ./node_modules
COPY --from=deps /repo/packages/db/node_modules ./packages/db/node_modules
COPY --from=deps /repo/worker/node_modules ./worker/node_modules
COPY . .
RUN pnpm --filter @afis/db build && pnpm --filter @afis/worker build && \
pnpm --filter @afis/worker --prod --no-optional deploy /bundle && \
cp -r worker/dist /bundle/dist && \
cp worker/healthcheck.js /bundle/healthcheck.js
FROM node:${NODE_VERSION} AS runtime
WORKDIR /app
ENV NODE_ENV=production
RUN apk add --no-cache openssl && \
addgroup -g 1001 -S afis && \
adduser -u 1001 -S afis -G afis
COPY --from=build --chown=afis:afis /bundle ./
USER afis
HEALTHCHECK --interval=60s --timeout=10s --start-period=30s --retries=3 \
CMD node healthcheck.js || exit 1
CMD ["node", "dist/index.js"]
+57
View File
@@ -0,0 +1,57 @@
import { readFileSync, existsSync } from 'node:fs'
import path from 'node:path'
import { gzipSync } from 'node:zlib'
/**
* Bündel-Budget aus docs/09-performance-und-ux.md: die erste Route muss unter
* 120 kB JavaScript nach Kompression bleiben.
*
* Gemessen wird je Route die Vereinigung ihrer JavaScript-Chunks aus
* app-build-manifest.json, gzip-komprimiert. Das entspricht dem, was Next
* selbst als "First Load JS" ausweist. Cloudflare liefert Brotli aus, das ist
* noch etwas kleiner, gzip ist damit die vorsichtigere Schätzung.
*/
const BUDGET_BYTES = 120 * 1024
const appDir = process.cwd()
const manifestPath = path.join(appDir, '.next', 'app-build-manifest.json')
if (!existsSync(manifestPath)) {
console.error('Kein Build gefunden. Zuerst `pnpm --filter @afis/web run build` laufen lassen.')
process.exit(1)
}
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'))
const routes = Object.entries(manifest.pages ?? {})
if (routes.length === 0) {
console.error('Keine Routen im Manifest gefunden.')
process.exit(1)
}
const measured = routes
.map(([route, files]) => {
let bytes = 0
for (const file of new Set(files)) {
if (!file.endsWith('.js')) continue
const full = path.join(appDir, '.next', file)
if (!existsSync(full)) continue
bytes += gzipSync(readFileSync(full)).byteLength
}
return { route, bytes }
})
.sort((a, b) => b.bytes - a.bytes)
const kb = (n) => `${(n / 1024).toFixed(1)} kB`
for (const { route, bytes } of measured) {
console.log(`${bytes > BUDGET_BYTES ? '✗' : '✓'} ${route.padEnd(28)} ${kb(bytes)}`)
}
const worst = measured[0]
console.log(`\nGrößte Route: ${worst.route} mit ${kb(worst.bytes)}, Budget ${kb(BUDGET_BYTES)}`)
if (worst.bytes > BUDGET_BYTES) {
console.error(`Budget überschritten um ${kb(worst.bytes - BUDGET_BYTES)}.`)
process.exit(1)
}