# 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`. - **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`](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:@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//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 && 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: /:` 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. ### 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` | 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: ```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 `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: ```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:///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.