Files
alpenwerk-hr/DEPLOYMENT.md
Maximilian Stubhan b87c8ad64c
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m40s
CI / Migrationen auf leerer Datenbank (push) Has been cancelled
Remove Supabase
The database moved to a container of our own; the platform is gone.
This takes out what was left of it — and, where the leftovers were load
bearing, moves rather than deletes.

Moved, not deleted:

  supabase/migrations/  -> db/migrations/      the schema's source of truth
  supabase/build-org.ts -> scripts/build-org.ts
  lib/supabase/types.ts -> lib/types.ts        52 import sites repointed

The bookkeeping needed care. It lived in `supabase_migrations.schema_migrations`,
and simply renaming the schema would have left the runner facing an empty
table: it would have called all 67 migrations pending and replayed them
against a database that is long since current. So the runner now creates
`migrationen.schema_migrations` and, once, copies the old rows across —
guarded so a second run does nothing and a fresh database skips it entirely.
Only then does migration 20260907100000 drop the old schema.

Deleted: the CLI config, the seed, the historical schema/function dumps
(nothing read them), scripts/umzug-von-supabase.sh (the move is done), and
both Supabase packages plus the CLI. Nothing in the application imported
them — the build now succeeds with no environment variables at all, which
is the proof.

Integration tests: six of them signed in through Supabase Auth and asserted
against the anon key and the service role. That model is gone, so the tests
were not portable — they are deleted. session-context and
employee-status-filter already ran on pg and are untouched; om-reporting is
ported to a direct connection because it guards a real risk (the reporting
line rule exists twice, once in SQL and once in TypeScript).

CI: the integration job started a Supabase stack. It now runs a postgres
service, applies deploy/db-init and every migration to an empty database —
that was the valuable part, and it still holds — then checks that a second
run is a no-op, which is what proves the bookkeeping works.

Docs: security-review.md audited a service-role key, a cookie adapter and
auth.users, none of which exist. Restating findings about removed components
would suggest today's system had been reviewed; it has not. It now records
what was removed and says a fresh review is due. data-model.md was already
marked obsolete and described the pre-OM schema; azure-migration.md was a
plan for a route not taken. Both deleted.

Verified: npm ci, typecheck, lint, 445 tests, build — all clean without the
packages. Integration tests skip cleanly with no database. Migration SQL and
the runner are reviewed but NOT executed: no Docker here, and the old
instance no longer resolves.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-07 10:43:22 +02:00

16 KiB
Raw Blame History

Deployment

Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server, zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt keine Werte, die beim Bauen eingebacken werden; alles kommt zur Laufzeit aus .env.

Zu klären, bevor es losgeht: die Umgebungsvariablen aus Abschnitt 1, die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach ihrer ersten Anmeldung eine profiles-Zeile braucht (siehe docs/entra-sso.md).

Was wird containerisiert – und was nicht

  • Containerisiert: die Next.js-App (Dockerfile) und die Datenbank (Dienst db, postgres:17-alpine). Beide zusammen in docker-compose.yml; die Daten liegen im benannten Volume db-daten.
  • Die Datenbank gehört zum Projekt. Was eine gehostete Plattform sonst beisteuert, bringt deploy/db-init/ mit: die Anwendungsrolle ohne BYPASSRLS, die zwei benutzten Erweiterungen und eine Attrappe des auth-Schemas, die nur die Migrationen von Juni 2026 brauchen.
  • Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech), genügt es, DATABASE_URL dorthin zeigen zu lassen und den db-Dienst nicht zu starten. Die Anwendung merkt keinen Unterschied.
  • Ebenfalls nicht containerisiert: die Anmeldung. Sie läuft über Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt keinen Anmeldedienst, der mit ausgerollt werden müsste.
  • Der nächtliche Lauf ist ein eigener Container (cron): täglich 03:00 Uhr ruft er /api/cron/apply-pending-changes auf, um fällige Versetzungen, Beförderungen, Karenz- und Reorg-Änderungen zu übernehmen. Ausgewiesen wird der Aufruf über CRON_SECRET — es gibt dabei keine angemeldete Person.

Betrieb im Firmennetz — zwei Dinge vorab

Die App läuft intern, aber sie ist nicht von der Aussenwelt unabhängig. Beides vor der Installation klären, sonst scheitert es am Ende an der Firewall:

1. Der Server braucht ausgehenden Zugang. Eingehend aus dem Internet nichts, ausgehend zwingend:

Ziel Wofür Ohne das
login.microsoftonline.com (443) Auth.js tauscht den Anmeldecode serverseitig gegen ein Token und lädt die Konfiguration des Ausstellers keine Anmeldung möglich

Dass die Anmeldung im Browser der Person stattfindet, genügt nicht — der Tausch von Code gegen Token läuft vom Server aus. Liegt die VM in einem abgeschotteten Netz, braucht es dafür einen Proxy.

Die Datenbank taucht hier nicht mehr auf: sie läuft als Container daneben, im selben Compose-Netz. Nach aussen geht dafür nichts.

2. HTTPS ist Pflicht, auch intern. Entra ID akzeptiert http nur für localhost. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein öffentlicher DNS-Name, der auf die private Adresse zeigt (z. B. hr.elycon.solutions → 10.x.x.x) und ein Zertifikat über die DNS-Challenge. Das ist zulässig, verbreitet, und liefert ein regulär vertrauenswürdiges Zertifikat, ohne dass der Server je aus dem Internet erreichbar ist.

Beispielkonfiguration: deploy/Caddyfile.

Die Alternative — selbst signiertes Zertifikat — bedeutet, es auf jedem Arbeitsplatz als vertrauenswürdig zu hinterlegen. Bei zwei Personen machbar, bei zwanzig nicht.

Voraussetzungen

  • Docker + Docker Compose (v2, das in Docker Engine enthaltene docker compose) auf dem Zielserver. Sonst nichts — weder Node noch psql; was gebraucht wird, kommt aus Containern.

1. .env anlegen

cp .env.example .env

Werte eintragen:

Variable Woher
POSTGRES_PASSWORD selbst erzeugen: openssl rand -base64 24 — das des Verwalters
APP_DB_PASSWORD selbst erzeugen — das der Anwendungsrolle alpenwerk_app
DATABASE_URL postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk. Die Rolle darf kein BYPASSRLS haben — deploy/db-init legt sie genau so an
DATABASE_SSL false im Compose-Netz; die Verbindung verlässt den Server nicht
AUTH_SECRET selbst generieren: openssl rand -base64 32
AUTH_MICROSOFT_ENTRA_ID_ID Entra-Portal → App-Registrierung → Übersicht
AUTH_MICROSOFT_ENTRA_ID_SECRET Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!)
AUTH_MICROSOFT_ENTRA_ID_ISSUER https://login.microsoftonline.com/<verzeichnis-id>/v2.0
CRON_SECRET selbst generieren: openssl rand -hex 32

Details zur Entra-Registrierung: docs/entra-sso.md.

Wichtig zum Verständnis:

  • Nichts davon wird in das Image eingebacken. Es gibt keine NEXT_PUBLIC_*-Variablen mehr; alle Werte liest die Anwendung zur Laufzeit über env_file. Eine Änderung braucht deshalb nur einen Neustart, keinen neuen Build — und dasselbe Image läuft in Test und Produktion.
  • .env steht schon in .gitignore – nicht committen.

2. Bauen und lokal testen

docker compose build
docker compose up -d
docker compose logs -f app

App ist danach unter http://localhost:3000 erreichbar. Healthcheck prüft GET /login; Status siehe docker compose ps.

Cron-Sidecar prüfen:

docker compose logs -f cron

3. Auf einem Server deployen

Einfachste Variante – Repo direkt auf dem Server bauen:

git clone <repo-url> && cd manner-app
cp .env.example .env   # Werte eintragen
docker compose up -d --build

Alternative für CI/CD (Image einmal bauen, überall pullen): Image in einer Registry bauen und pushen, auf dem Server nur docker compose pull && docker compose up -d ausführen. Dafür in docker-compose.yml zusätzlich image: <registry>/<name>:<tag> setzen.

Build-Argumente braucht es dabei keine: das Abbild enthält keine umgebungsabhängigen Werte mehr, alles kommt zur Laufzeit aus .env. Dasselbe Abbild läuft damit in Test und Produktion.

Bei einer leeren Datenbank

Der db-Container legt beim ersten Start Rollen und Erweiterungen an; das Schema kommt danach aus den Migrationen:

docker compose up -d db
docker compose run --rm migrate      # legt Tabellen, Funktionen, Policies an
docker compose up -d --build

migrate steht im Profil tools und läuft bei docker compose up nicht mit. Auf dem Host wird dafür weder Node noch psql gebraucht — nur Docker.

Was am auth-Schema übrigbleibt

deploy/db-init/01-auth-attrappe.sql legt ein leeres auth-Schema an. Es wird im Betrieb nicht gebraucht: kein Fremdschlüssel, keine Policy, keine Spaltenvorgabe verweist darauf (geprüft). Gebraucht wird es nur beim Abspielen der Migrationen von Juni 2026, die damals noch an auth.users hingen. Die Alternative wäre, jene Dateien umzuschreiben — also Geschichte zu fälschen: sie beschreiben, was damals galt.

4. Reverse Proxy + HTTPS

Next.js selbst sollte laut den offiziellen Docs nicht direkt exponiert werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request- Validierung.

Fertige Konfiguration: deploy/Caddyfile — mit DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe oben).

sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy

docker-compose.yml veröffentlicht Port 3000 bewusst nur auf 127.0.0.1 — die App ist also ausschliesslich über den Proxy erreichbar, nicht daneben unverschlüsselt.

Zusätzlich in die .env:

AUTH_URL=https://hr.elycon.solutions

Ohne diesen Wert baut Auth.js seine Rückruf-Adresse aus dem, was der Container sieht — und das ist hinter dem Proxy nicht der Name, den der Browser benutzt hat.

5. Automatisch ausrollen bei einem Push (Gitea Actions)

Ein Push auf master baut und startet die Anwendung auf dem Server neu. Zuständig ist .github/workflows/deploy.yml.

Warum .github/ und nicht .gitea/: Gitea Actions liest .gitea/workflows — und nur wenn dieses Verzeichnis fehlt, ersatzweise .github/workflows. Ein neu angelegtes .gitea/workflows/ würde also ci.yml stillschweigend abschalten. Solange beide Dateien unter .github/ liegen, sieht Gitea beide.

a) Der Runner

Verwendet wird der bestehende, globale Runner elycon-runner-01 (act_runner v0.2.11, Labels docker und ubuntu-latest). Ein eigener ist nicht nötig.

Beide Workflows laufen deshalb auf ubuntu-latest. Ein Label, das kein Runner führt, lässt den Lauf wortlos in der Warteschlange stehen — kein Fehler, keine Meldung, nur „Waiting". Das ist der häufigste Grund, warum scheinbar nichts passiert.

Der Job läuft im Container, nicht auf dem Server

Das ist die Stelle, an der es klemmen kann. act_runner startet je Job einen eigenen Container; der Docker-Socket des Hosts ist darin nur, wenn der Runner so konfiguriert ist:

# config.yaml des Runners
container:
  options: -v /var/run/docker.sock:/var/run/docker.sock
  valid_volumes:
    - /var/run/docker.sock

Zusätzlich muss das Job-Abbild die Docker-CLI mitbringen (die üblichen runner-images/catthehacker-Abbilder tun das).

Der erste Schritt des Deploy-Workflows prüft genau das und bricht mit einer Anleitung ab, wenn etwas fehlt — statt drei Schritte später an einer Meldung zu scheitern, mit der niemand etwas anfangen kann.

Der durchgereichte Socket gibt dem Job faktisch Wurzelrechte auf dem Host. Bei einem Deploy-Runner ist das der Zweck der Übung, aber es heisst auch: wer in dieses Repository schreiben darf, darf auf diesem Server alles. Bei einem globalen Runner gilt das für jedes Repository der Instanz, das denselben Socket benutzt. Ist das zu weit gefasst, ist der Weg über SSH der richtige: ein Schlüssel als Repository-Secret, der nur auf den einen Server und nur auf das Deploy-Verzeichnis zeigt.

b) Secrets hinterlegen

Gitea → Repository → Settings → Actions → Secrets. Dieselben Werte wie in Abschnitt 1; der Workflow baut daraus zur Laufzeit die .env und löscht sie danach wieder.

Secret Anmerkung
DATABASE_URL Verbindung der Anwendungsrolle — die benutzt die Anwendung
MIGRATE_DATABASE_URL Zugang als Verwalter — nur für die Migrationen
AUTH_SECRET
AUTH_URL z. B. https://hr.elycon.solutions
AUTH_MICROSOFT_ENTRA_ID_ID
AUTH_MICROSOFT_ENTRA_ID_SECRET
AUTH_MICROSOFT_ENTRA_ID_ISSUER
CRON_SECRET

Warum zwei Verbindungsstrings: der Transaktions-Pooler bricht bei einer Migration ab, die mehrere Anweisungen in einer Transaktion bündelt. Migrationen gehören über die direkte Verbindung, der laufende Betrieb über den Pooler.

Zusätzlich unter Variables (kein Secret, nur eine Einstellung):

Variable Vorgabe Wofür
COMPOSE_PROJECT_NAME alpenwerk-hr Muss zum bestehenden Stapel passen — sonst entsteht ein zweiter daneben

Den bestehenden Namen zeigt auf dem Server:

docker compose ls

c) Was der Lauf tut

  1. .env aus den Secrets schreiben (fehlt eines, bricht er mit Namen ab).
  2. Migrationen anwenden — erst --dry-run fürs Protokoll, dann echt.
  3. docker compose build und up -d gegen den Docker-Dienst des Hosts.
  4. Auf healthy warten (bis zu zwei Minuten). Wird die Anwendung nicht gesund, schlägt der Lauf fehl und hängt die letzten 80 Logzeilen an.
  5. .env wieder löschen.

Der Workflow ist nicht an ci.yml gekoppelt: ein Push auf master rollt aus, auch wenn die Tests parallel noch laufen. Soll erst nach grünen Tests ausgerollt werden, gehört der deploy-Job mit needs: [check] in ci.yml — dann allerdings braucht der Runner auch das Label, mit dem check läuft.

5a. Migrationen

Eingespielt werden sie von scripts/migrate.mjs — demselben Läufer, den auch der Deploy benutzt:

node --env-file=.env scripts/migrate.mjs --dry-run   # was stünde an
node --env-file=.env scripts/migrate.mjs             # anwenden

Buch geführt wird in migrationen.schema_migrations: eine Zeile je angewendeter Datei, mit ihrem Text. Der Läufer wendet nur an, was dort fehlt.

Einmalig, im August 2026 bereits erledigt: Die Migrationen wurden bis dahin von Hand eingespielt, die Buchführungstabelle existierte gar nicht. Ein automatischer Lauf hätte deshalb alle 65 Dateien erneut gegen die produktive Datenbank gespielt — inklusive initial_schema und der OM-Umstellung. Vor dem Einschalten wurde einmal node scripts/migrate.mjs --baseline gefahren: das verbucht alles Vorhandene als angewendet, ohne es auszuführen. Wer eine weitere Umgebung aufsetzt, deren Datenbank schon steht, braucht denselben Schritt.

5b. Updates von Hand ausrollen

Falls der Runner nicht läuft oder ein Stand ausser der Reihe gebraucht wird:

git pull
node --env-file=.env scripts/migrate.mjs
docker compose build
docker compose up -d

Kurzer Downtime-Moment beim Neustart des app-Containers ist bei dieser Single-Instance-Compose-Konfiguration normal. Für Zero-Downtime-Deployments wäre eine zweite Instanz + Load Balancer nötig (siehe Abschnitt 6).

Der Migrationsschritt steht bewusst vor dem Neubau: der neue Code erwartet das neue Schema, und umgekehrt liefe die neue Anwendung kurz gegen das alte.

6. Hinweis bei mehreren Replicas

Läuft die App skaliert (mehrere app-Container hinter einem Load Balancer), muss NEXT_SERVER_ACTIONS_ENCRYPTION_KEY explizit gesetzt und auf allen Instanzen identisch sein – sonst schlagen Server Actions (actions/*.ts, z. B. Mitarbeiter- und Positions-Mutationen) mit "Failed to find Server Action" fehl, wenn eine Anfrage auf einer anderen Instanz landet als der, die das Formular gerendert hat. Erzeugen mit:

openssl rand -base64 32

Als zusätzliche Env-Variable in .env eintragen. Bei der aktuellen Single-Instance-Compose-Konfiguration ist das nicht nötig.

Troubleshooting

  • Push gemacht, aber kein Lauf startet: meist die Labels. Der Lauf steht dann in Gitea unter Actions als „Waiting" — ohne Fehlermeldung, weil kein Runner das verlangte Label anbietet. Beide Workflows verlangen ubuntu-latest; elycon-runner-01 führt es. Welche Labels registriert sind, zeigt Gitea → Site Administration → Actions → Runners.

  • Deploy läuft, aber es entsteht ein zweiter Stapel: COMPOSE_PROJECT_NAME passt nicht zum bestehenden. Auf dem Server docker compose ls — der dort gelistete Name gehört als Variable ins Repository. Bis dahin läuft die alte Instanz weiter und beansprucht Port 3000; die neue scheitert daran.

  • Cannot connect to the Docker daemon im Deploy: dem Runner-Container fehlt der Socket. Er braucht -v /var/run/docker.sock:/var/run/docker.sock.

  • Migration schlägt im Deploy fehl: der Lauf bricht ab, bevor Container angefasst werden — die alte Version läuft also weiter. Die fehlgeschlagene Datei wurde vollständig zurückgerollt und nicht verbucht; nach der Korrektur reicht ein erneuter Push.

  • Anmeldung endet auf /login?error=…: die Umleitungs-URI in der Entra-Registrierung muss exakt https://<host>/api/auth/callback/microsoft-entra-id lauten. Steht die Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie selbst sieht, zusätzlich AUTH_URL setzen.

  • Angemeldet, aber sofort zurück auf /login?error=no_hr_access: die Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eine profiles-Zeile mit role = 'hr' und is_active = true auf derselben Kennung, die in app_users steht.

  • Cron läuft nicht: docker compose logs cron – prüft, ob /etc/crontabs/root korrekt geschrieben wurde und ob CRON_SECRET in .env gesetzt ist (leer/fehlend führt serverseitig zu 401).

  • Healthcheck rot: docker compose logs app – meist DATABASE_URL fehlend oder nicht erreichbar. Der Pool baut die Verbindung erst beim ersten Zugriff auf, der Fehler steht deshalb im Log der Anfrage, nicht im Start-Log.