Auth.js replaces GoTrue. The sign-in still goes to the same Entra tenant,
but nothing sits between the app and the identity provider any more — the
code exchange, state, nonce and the session cookie are ours.
lib/auth/session.ts stays the only place that knows where a user id comes
from, which is why this was one file and not fifty. What it returns is now
app_users.id. app_upsert_user() maps the Entra `oid` onto it, and for an
address that already has a profiles row it adopts that id instead of
minting a new one — otherwise everyone would have been signed in and cut
off from their own notes, drafts and audit trail at the same time.
That upsert is the one write that cannot have a session context yet: the
id is what it produces. It runs as a SECURITY DEFINER function that may
touch app_users and nothing else, which is a far smaller lever than the
service key that used to answer this class of problem.
The proxy no longer checks HR rights. It has no database connection, and
putting role/is_active in the token would have frozen the claim until the
next sign-in. The check moved to where it can read the current truth: the
app layout on every render, requireHrUser() for the export routes, and
underneath both, RLS.
Two things only came out by running it:
- `export const proxy = auth(…)` is not a function declaration, so
Next.js never found it and every request 404'd. `next build` reported
success and listed the proxy. In the function config form auth() also
returns the handler as a promise, so it needs an await. The proxy test
now mocks it as a promise for that reason — a friendlier mock would
let the same bug back in.
- A missing AUTH_MICROSOFT_ENTRA_ID_ISSUER silently falls back to
/common/, and the redirect really did go there. That would let any
Microsoft account sign in, including a private one, and it would never
look broken. It now refuses to start in production.
Neither build nor image needs credentials any more: the pool is created on
first use, the auth config is evaluated per request, and there are no
NEXT_PUBLIC_* values left to bake in. One image now runs in every
environment.
Verified: typecheck, lint, 187 tests, build, and by hand in the browser —
/employees redirects to /login, and the sign-in button reaches the Entra
page with PKCE and the callback URL that goes into the app registration.
Not verified against a real database; there is still no DATABASE_URL.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
6.0 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 |
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). - 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.