Supabase was only ever the host: the application has talked to PostgreSQL
directly through pg/Kysely for a while. So the move is mostly about
supplying what the platform used to supply.
Proved before building anything. All 65 migrations replay onto an empty
database, and the result matches production exactly — 183 columns, 25
policies, 68 indexes, 84 constraints, identical sets, no diff. The only
function missing from the rebuild turned out to matter, see below.
What the platform supplied, deploy/db-init now does:
- alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21
RLS policies; a role that bypasses them would leave everything
working while showing too much, and nobody would notice.
- pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is
used nowhere — no column default, no function calls uuid_generate_*.
- anon, authenticated and service_role as NOLOGIN placeholders. No
policy names them; they only carry grants the platform handed out,
and a data dump referencing them would fail to restore without them.
- A stub `auth` schema. The end state needs none of it — checked: no
foreign key, no policy, no column default refers to it. The June
2026 migrations do, and rewriting those would be falsifying history;
they describe what was true then.
The gap the comparison found: rls_auto_enable() and the ensure_rls event
trigger existed only in the running database, created by hand, in no
migration. That is the net which forces RLS on every newly created
table — the reason a forgotten policy yields an empty table instead of
an open one. A rebuild from migrations would silently not have had it:
everything works, and the next new table is unprotected. Now a migration
(20260819100000), verified by creating a table on the rebuild and
confirming RLS came on by itself.
Data moves separately, via scripts/umzug-von-supabase.sh: schema from
the migrations, then pg_dump --data-only --disable-triggers for the rows.
Without --disable-triggers every foreign key trips over load order. RLS
does not interfere — none of the 19 tables uses FORCE ROW LEVEL
SECURITY, so the owner writes through. The dump is deliberately left on
disk afterwards.
psql and node come from two `tools`-profile services rather than being
installed on the host, so the server needs nothing but Docker. The db
service publishes no port at all — reachable only inside the compose
network.
SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it
describes something else entirely; the old name still works so existing
.env files keep running. Both were exercised, as was the error when
neither is set.
The deploy workflow is set to manual-only. Its preconditions were never
met — no secrets, and whether the job container can reach the host's
Docker daemon is untested — and failing on every push teaches people to
ignore red runs. It also needs updating for the new database service
before it could work at all.
Not verified: none of this has run in an actual container. There is no
Docker daemon on this machine. What is verified is the part that
decides whether it can work — the schema, on a real empty database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
477 lines
20 KiB
Markdown
477 lines
20 KiB
Markdown
# Deployment
|
||
|
||
Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine
|
||
Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also
|
||
jederzeit möglich.
|
||
|
||
| | passt, wenn |
|
||
|---|---|
|
||
| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg |
|
||
| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll |
|
||
|
||
**In beiden Fällen gleich:** 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)).
|
||
|
||
## Vercel
|
||
|
||
Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen
|
||
Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses
|
||
Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten:
|
||
|
||
### a) Von der Arbeitsstation ausrollen (ohne GitHub)
|
||
|
||
```bash
|
||
npx vercel login
|
||
npx vercel link
|
||
npx vercel --prod
|
||
```
|
||
|
||
Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen
|
||
bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei
|
||
Personen ist das eher Vorteil als Nachteil.
|
||
|
||
### b) Zusätzlich nach GitHub spiegeln
|
||
|
||
```bash
|
||
git remote add github git@github.com:<konto>/alpenwerk-hr.git
|
||
git push github feat/sap-om-org-model
|
||
```
|
||
|
||
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
|
||
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
|
||
|
||
### Danach
|
||
|
||
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
|
||
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
|
||
nicht nötig, Vercel setzt den Host selbst.
|
||
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
|
||
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
|
||
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
|
||
|
||
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
|
||
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
|
||
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
|
||
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
|
||
vielen tausend Zeilen ansteht.
|
||
|
||
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
|
||
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
|
||
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
|
||
verbraucht.
|
||
|
||
## Deployment mit Docker
|
||
|
||
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
|
||
einem eigenen Linux-Server läuft.
|
||
|
||
## 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.
|
||
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr,
|
||
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/
|
||
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von
|
||
Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml`
|
||
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
|
||
und demselben Bearer-Secret, das die Route bereits erwartet.
|
||
|
||
## 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 |
|
||
| Die Datenbank (Supabase: `*.pooler.supabase.com`, 6543) | jede Abfrage | die App startet, zeigt aber nichts |
|
||
|
||
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, ist entweder ein Proxy nötig oder eine PostgreSQL-
|
||
Instanz im selben Netz statt Supabase.
|
||
|
||
**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 im Docker Desktop/Docker Engine
|
||
enthaltene `docker compose`) auf dem Zielserver.
|
||
- Ein bestehendes Supabase-Projekt mit den Migrationen aus
|
||
`supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie
|
||
bisher).
|
||
|
||
## 1. `.env` anlegen
|
||
|
||
```bash
|
||
cp .env.example .env
|
||
```
|
||
|
||
Werte eintragen:
|
||
|
||
| Variable | Woher |
|
||
|---|---|
|
||
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) |
|
||
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft |
|
||
| `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.
|