This reverts commit ecbda3f. The deployment goes to a Linux server instead,
so the two accommodations no longer earn their place: output: "standalone"
returns to unconditional, which is what the Dockerfile wants, and
/api/import goes back to 120 seconds — the free-tier ceiling that forced 60
does not apply outside a serverless platform, and a large import benefits
from the headroom.
The Vercel section in DEPLOYMENT.md goes with it.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.3 KiB
Deployment mit Docker
Dieser Guide beschreibt, wie die App (bisher auf Vercel deployed, siehe
vercel.json) stattdessen als Docker-Container auf einem beliebigen Server
läuft.
Was wird containerisiert – und was nicht
- Containerisiert: nur die Next.js-App selbst (
Dockerfile). - Nicht containerisiert: Supabase (Datenbank + Auth). Die App verbindet
sich per URL/Key zu einem bestehenden Supabase-Projekt (Cloud oder
selbst gehostet) – das bleibt unverändert.
supabase/in diesem Repo ist nur die lokale Dev-/Migrations-Umgebung (supabase start), kein Teil des Deployments. - 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.
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 (GHCR, Docker Hub, …) 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 und
den Build in der CI-Pipeline mit den --build-arg-Werten für
NEXT_PUBLIC_* laufen lassen.
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. Beispiel mit Caddy (automatisches HTTPS via Let's Encrypt):
# /etc/caddy/Caddyfile
hr.example.com {
reverse_proxy localhost:3000
}
docker-compose.yml published Port 3000 aktuell auf den Host – bei
Verwendung eines Reverse Proxys auf demselben Host kann das Publishing auf
127.0.0.1:3000:3000 eingeschränkt werden, damit der Container-Port nicht
direkt von außen erreichbar ist.
5. Updates ausrollen
git pull
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).
Datenbank-Migrationen (supabase/migrations/*.sql) werden weiterhin über
die Supabase CLI gegen das Supabase-Projekt gefahren, unabhängig vom
App-Deployment:
supabase db push
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
- 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.