Files
alpenwerk-hr/DEPLOYMENT.md
Maximilian Stubhan e958bb5c6b
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m51s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m15s
Stop pretending Vercel is an option
It was never used. The repository lives on a self-hosted Gitea, which
Vercel's git integration cannot connect to at all — so the documented
route amounted to "mirror to GitHub first", and nobody did.

vercel.json is gone, and with it the branch in next.config.ts that
switched off `output: "standalone"` when the VERCEL variable was
present. That branch was the only functional trace; everything else was
documentation and comments describing a second deployment path that did
not exist.

DEPLOYMENT.md loses its "two supported ways" framing and the whole
Vercel section — about fifty lines. Several statements next to it were
stale for a different reason and are corrected in the same pass: the
outbound-firewall table still listed Supabase's pooler (the database is
a container now, nothing leaves the server), the prerequisites still
demanded an existing Supabase project, and the .env table still asked
for a pooler connection string instead of the two new passwords.

The nightly job is described as what it is — a container in
docker-compose.yml — rather than as a replacement for Vercel Cron.

Migrations keep their references: two comments from July mention Vercel
Cron, and they describe what was true when they were written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 11:02:40 +02:00

419 lines
18 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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](#1-env-anlegen), 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](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`.
- **Nicht mehr Supabase.** Die Anwendung sprach ohnehin unmittelbar mit
PostgreSQL — Supabase war nur der Betreiber. Was die Plattform beisteuerte,
bringt jetzt `deploy/db-init/` mit: die Anwendungsrolle ohne BYPASSRLS, die
zwei benutzten Erweiterungen und eine Attrappe des `auth`-Schemas, die nur
die alten Migrationen von Juni brauchen. Der Umzug steht in
[Abschnitt 3a](#3a-umzug-von-supabase).
- 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`](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
```bash
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`](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
```bash
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:
```bash
docker compose logs -f cron
```
## 3. Auf einem Server deployen
Einfachste Variante – Repo direkt auf dem Server bauen:
```bash
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:
```bash
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.
## 3a. Umzug von Supabase
Einmalig, wenn der Bestand noch bei Supabase liegt:
```bash
SUPABASE_DB_URL='postgresql://postgres:<passwort>@<projekt>.supabase.com:5432/postgres' \
./scripts/umzug-von-supabase.sh
```
Was das Skript tut und warum:
1. **Schema aus den Migrationen**, nicht aus einem Abzug. Nachgewiesen ist,
dass alle Migrationen auf einer leeren Datenbank durchlaufen und dabei
Spalte für Spalte, Index für Index, Policy für Policy dasselbe ergeben wie
die gewachsene Produktion. Der Weg hat zwei Vorteile: die Buchführung
stimmt danach von selbst, und Supabase-eigene Rechte und Eigentümer kommen
gar nicht erst mit.
2. **Nur die Daten** werden abgezogen (`pg_dump --data-only
--disable-triggers`). Ohne `--disable-triggers` stolpert jede
Fremdschlüsselprüfung über die Ladereihenfolge. RLS steht nicht im Weg —
keine der 19 Tabellen hat `FORCE ROW LEVEL SECURITY`, der Eigentümer
schreibt also durch.
3. Am Ende werden die Zeilenzahlen ausgegeben. **Mit denen bei Supabase
vergleichen**, bevor irgendetwas abgeschaltet wird.
Der Abzug bleibt als Datei liegen. Erst löschen, wenn die Anwendung gegen die
neue Datenbank nachweislich läuft.
Danach in `.env`:
```
DATABASE_URL=postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk
DATABASE_SSL=false
```
### 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`](deploy/Caddyfile) — mit
DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe
[oben](#betrieb-im-firmennetz--zwei-dinge-vorab)).
```bash
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`](.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:
```yaml
# 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](#1-env-anlegen); der Workflow baut daraus zur Laufzeit die `.env`
und löscht sie danach wieder.
| Secret | Anmerkung |
|---|---|
| `DATABASE_URL` | Transaktions-Pooler (Supabase: 6543) — den benutzt die Anwendung |
| `SUPABASE_DB_URL` | **Direkter** Zugang (Supabase: 5432) — 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:
```bash
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`](scripts/migrate.mjs) —
demselben Läufer, den auch der Deploy benutzt:
```bash
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 `supabase_migrations.schema_migrations`, derselben Tabelle
in derselben Form, die die Supabase-CLI benutzt — `supabase db push` von der
Arbeitsstation bleibt damit möglich und überspringt, was hier schon lief.
> **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:
```bash
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:
```bash
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`).
- **`max clients reached in session mode`:** der Verbindungsstring zeigt auf
den Sitzungs-Modus des Poolers. Auf den Transaktions-Modus wechseln (bei
Supabase Port 6543).
- **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.