The target is a VM inside the company network, reachable only from there. Two consequences decide whether this works at all, and both are easy to discover too late — after the firewall rules are already written. The server needs outbound access even though nothing comes in. Auth.js exchanges the authorisation code for a token server-side and fetches the issuer's configuration, so login.microsoftonline.com must be reachable from the VM; the database likewise. That the person signs in through their own browser is not enough, which is the assumption worth naming before someone builds a closed network around it. HTTPS is not optional either: Entra accepts http only for localhost. The practical route without public reachability is a public DNS name pointing at a private address and a certificate obtained through the DNS challenge — allowed, common, and it yields a normally trusted certificate while the server stays unreachable from outside. deploy/Caddyfile does that, and the alternative (self-signed, trusted on every workstation) is written down with its cost. docker-compose now publishes port 3000 on 127.0.0.1 only. It was on every interface, so the same service also stood there unencrypted, and one gap in the firewall was enough. The proxy is the only way in. AUTH_URL is documented for the same reason a comment sits in the Caddyfile: behind a proxy the container does not see the name the browser used, and the callback would point somewhere nobody can reach. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
215 lines
8.3 KiB
Markdown
215 lines
8.3 KiB
Markdown
# Deployment mit Docker
|
||
|
||
Dieser Guide beschreibt, wie die App 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_URL` zu 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-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.
|
||
|
||
## 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`](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 push` bzw. wie
|
||
bisher).
|
||
|
||
## 1. `.env` anlegen
|
||
|
||
```bash
|
||
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`](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 <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`](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. Updates ausrollen
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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:
|
||
|
||
```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
|
||
|
||
- **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.
|