Write down what an internal deployment actually needs

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>
This commit is contained in:
2026-08-07 08:26:14 +02:00
parent 56662c0775
commit 780f8fe2b7
3 changed files with 104 additions and 12 deletions

View File

@@ -21,6 +21,39 @@ Linux-Server läuft.
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
und demselben Bearer-Secret, das die Route bereits erwartet. 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 ## Voraussetzungen
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine - Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine
@@ -97,20 +130,30 @@ Dasselbe Abbild läuft damit in Test und Produktion.
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert
werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request- werden – ein Reverse Proxy übernimmt TLS, Rate-Limiting und Request-
Validierung. Beispiel mit [Caddy](https://caddyserver.com/) (automatisches Validierung.
HTTPS via Let's Encrypt):
```caddyfile Fertige Konfiguration: [`deploy/Caddyfile`](deploy/Caddyfile) — mit
# /etc/caddy/Caddyfile DNS-Challenge, weil der Server aus dem Internet nicht erreichbar ist (siehe
hr.example.com { [oben](#betrieb-im-firmennetz--zwei-dinge-vorab)).
reverse_proxy localhost:3000
} ```bash
sudo cp deploy/Caddyfile /etc/caddy/Caddyfile
sudo systemctl reload caddy
``` ```
`docker-compose.yml` published Port 3000 aktuell auf den Host – bei `docker-compose.yml` veröffentlicht Port 3000 bewusst nur auf
Verwendung eines Reverse Proxys auf demselben Host kann das Publishing auf `127.0.0.1` — die App ist also ausschliesslich über den Proxy erreichbar,
`127.0.0.1:3000:3000` eingeschränkt werden, damit der Container-Port nicht nicht daneben unverschlüsselt.
direkt von außen erreichbar ist.
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 ## 5. Updates ausrollen

45
deploy/Caddyfile Normal file
View File

@@ -0,0 +1,45 @@
# Reverse Proxy für den Betrieb im Firmennetz.
#
# Der Name ist öffentlich (hr.elycon.solutions), die Adresse dahinter privat
# (10.x). Das ist erlaubt und der übliche Weg: der DNS-Eintrag ist von aussen
# auflösbar, der Server nicht erreichbar.
#
# Warum das der Aufwand wert ist: Entra ID akzeptiert `http` nur für
# localhost. Ohne HTTPS gibt es keine Anmeldung — und ein selbst signiertes
# Zertifikat müsste auf jedem Arbeitsplatz als vertrauenswürdig hinterlegt
# werden. Mit der DNS-Challenge kommt ein regulär vertrauenswürdiges
# Zertifikat zustande, ohne dass der Server je aus dem Internet erreichbar
# sein muss.
#
# Voraussetzung: ein Caddy-Build mit dem DNS-Modul des eigenen Anbieters,
# etwa
# xcaddy build --with github.com/caddy-dns/cloudflare
# Andere Anbieter siehe https://github.com/caddy-dns
#
# Der API-Schlüssel gehört in eine Umgebungsvariable des Dienstes, nicht in
# diese Datei.
hr.elycon.solutions {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
# Die Anwendung lauscht nur auf der Loopback-Adresse (siehe
# docker-compose.yml), erreichbar ist sie also ausschliesslich über
# diesen Proxy.
reverse_proxy 127.0.0.1:3000
# Ohne diese Weitergabe baut Auth.js seine Rückruf-Adresse aus dem
# Container-Hostnamen statt aus dem echten Namen — die Anmeldung landet
# dann auf einer Adresse, die niemand kennt. `trustHost` in
# lib/auth/config.ts wertet genau diese Header aus.
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
encode gzip zstd
log {
output file /var/log/caddy/hr.log
format json
}
}

View File

@@ -6,8 +6,12 @@ services:
build: build:
context: . context: .
restart: unless-stopped restart: unless-stopped
# Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar
# ist die App damit ausschliesslich über den Reverse Proxy, der TLS
# beendet — sonst stünde daneben derselbe Dienst unverschlüsselt offen,
# und ein Fehler in der Firewall genügte.
ports: ports:
- "3000:3000" - "127.0.0.1:3000:3000"
env_file: env_file:
- .env - .env