The workflow asked for a `self-hosted` label. No runner on the instance offers one, so the run would have sat in "Waiting" forever — no error, no message, nothing to notice. The existing global runner elycon-runner-01 offers `docker` and `ubuntu-latest`, so both workflows now ask for `ubuntu-latest`, the same label ci.yml already used. That correction exposed a second thing the first version glossed over. act_runner starts a container per job; mounting the Docker socket into the *runner* does not put it in the *job*. Whether this job can reach the host's daemon depends on the runner's config.yaml, which is not visible from here — and the runner is global, so changing it affects every repository on the instance, not just this one. Rather than guess, the workflow now measures it in its first step and fails with the fix if it cannot: which config lines to add for the socket, or that SSH is the other way. Without that, the run would have died three steps later on a message nobody could act on. Both branches of the check were exercised: docker absent prints the first message, docker present with no reachable daemon the second. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
17 KiB
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 | ihr nichts betreiben wollt; schnellster Weg |
| Docker | es in eure eigene Infrastruktur soll |
In beiden Fällen gleich: 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).
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)
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
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
- Umgebungsvariablen im Vercel-Projekt setzen (Settings → Environment
Variables), dieselben wie in Abschnitt 1.
AUTH_URList nicht nötig, Vercel setzt den Host selbst. - Umleitungs-URI in der Entra-Registrierung ergänzen:
https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id - Der nächtliche Lauf ist über
vercel.jsonbereits 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: nur die Next.js-App selbst (
Dockerfile). - Nicht containerisiert: die Datenbank. Die App verbindet sich über
DATABASE_URLzu einem beliebigen PostgreSQL ab 15 — heute ein Supabase-Projekt, genauso möglich sind Azure Flexible Server, RDS, Cloud SQL oder eigenes Blech.supabase/in diesem Repo ist die Migrations- und Entwicklungsumgebung, kein Teil des Deployments. - 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-changesauf, um fällige Versetzungen/ Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von Vercel kein Vercel-Cron gibt, übernimmt das imdocker-compose.ymlenthaltenecron-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.
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 pushbzw. wie bisher).
1. .env anlegen
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.
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 überenv_file. Eine Änderung braucht deshalb nur einen Neustart, keinen neuen Build — und dasselbe Image läuft in Test und Produktion. .envsteht 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.
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 alsoci.ymlstillschweigend 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 |
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:
docker compose ls
c) Was der Lauf tut
.envaus den Secrets schreiben (fehlt eines, bricht er mit Namen ab).- Migrationen anwenden — erst
--dry-runfürs Protokoll, dann echt. docker compose buildundup -dgegen den Docker-Dienst des Hosts.- Auf
healthywarten (bis zu zwei Minuten). Wird die Anwendung nicht gesund, schlägt der Lauf fehl und hängt die letzten 80 Logzeilen an. .envwieder 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 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_schemaund der OM-Umstellung. Vor dem Einschalten wurde einmalnode scripts/migrate.mjs --baselinegefahren: 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-01führt es. Welche Labels registriert sind, zeigt Gitea → Site Administration → Actions → Runners. -
Deploy läuft, aber es entsteht ein zweiter Stapel:
COMPOSE_PROJECT_NAMEpasst nicht zum bestehenden. Auf dem Serverdocker 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 daemonim 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 exakthttps://<host>/api/auth/callback/microsoft-entra-idlauten. Steht die Anwendung hinter einem Reverse Proxy unter einer anderen Adresse, als sie selbst sieht, zusätzlichAUTH_URLsetzen. -
Angemeldet, aber sofort zurück auf
/login?error=no_hr_access: die Anmeldung hat funktioniert, es fehlt die Freischaltung. Es braucht eineprofiles-Zeile mitrole = 'hr'undis_active = trueauf derselben Kennung, die inapp_userssteht. -
Cron läuft nicht:
docker compose logs cron– prüft, ob/etc/crontabs/rootkorrekt geschrieben wurde und obCRON_SECRETin.envgesetzt ist (leer/fehlend führt serverseitig zu401). -
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– meistDATABASE_URLfehlend 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.