Files
alpenwerk-hr/DEPLOYMENT.md
Maximilian Stubhan 61ccce5456 Revert "Make the build fit Vercel without breaking the container"
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>
2026-08-07 08:21:58 +02:00

6.3 KiB
Raw Blame History

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-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.

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

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 ü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

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 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.