Stop pretending Vercel is an option
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m51s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m15s

It was never used. The repository lives on a self-hosted Gitea, which
Vercel's git integration cannot connect to at all — so the documented
route amounted to "mirror to GitHub first", and nobody did.

vercel.json is gone, and with it the branch in next.config.ts that
switched off `output: "standalone"` when the VERCEL variable was
present. That branch was the only functional trace; everything else was
documentation and comments describing a second deployment path that did
not exist.

DEPLOYMENT.md loses its "two supported ways" framing and the whole
Vercel section — about fifty lines. Several statements next to it were
stale for a different reason and are corrected in the same pass: the
outbound-firewall table still listed Supabase's pooler (the database is
a container now, nothing leaves the server), the prerequisites still
demanded an existing Supabase project, and the .env table still asked
for a pooler connection string instead of the two new passwords.

The nightly job is described as what it is — a container in
docker-compose.yml — rather than as a replacement for Vercel Cron.

Migrations keep their references: two comments from July mention Vercel
Cron, and they describe what was true when they were written.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-21 11:02:40 +02:00
parent 77d9a95f7f
commit e958bb5c6b
12 changed files with 54 additions and 127 deletions

View File

@@ -57,7 +57,9 @@ AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id
# Auth.js die Rückruf-Adresse aus den Request-Headern. # Auth.js die Rückruf-Adresse aus den Request-Headern.
AUTH_URL= AUTH_URL=
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it # Gemeinsames Geheimnis, mit dem sich der nächtliche Lauf ausweist: der
# calls /api/cron/apply-pending-changes (set the same value in the Vercel # `cron`-Container schickt es als `Authorization: Bearer <wert>` an
# project's env vars). Generate with e.g. `openssl rand -hex 32`. # /api/cron/apply-pending-changes. Ohne angemeldete Person gibt es keine
# Sitzung, an der die Route den Aufruf prüfen könnte.
# Erzeugen mit: openssl rand -hex 32
CRON_SECRET= CRON_SECRET=

3
.gitignore vendored
View File

@@ -34,9 +34,6 @@ yarn-error.log*
.env* .env*
!.env.example !.env.example
# vercel
.vercel
# typescript # typescript
*.tsbuildinfo *.tsbuildinfo
next-env.d.ts next-env.d.ts

View File

@@ -1,71 +1,14 @@
# Deployment # Deployment
Zwei Wege, beide unterstützt. Das Abbild ist umgebungsneutral — es gibt keine Die Anwendung läuft als Docker-Container auf einem eigenen Linux-Server,
Werte mehr, die beim Bauen eingebacken werden —, ein Wechsel ist also zusammen mit ihrer Datenbank. Das Abbild ist umgebungsneutral — es gibt
jederzeit möglich. keine Werte, die beim Bauen eingebacken werden; alles kommt zur Laufzeit
aus `.env`.
| | passt, wenn | Zu klären, bevor es losgeht: die Umgebungsvariablen aus
|---|---| [Abschnitt 1](#1-env-anlegen), die Umleitungs-URI in der
| [Vercel](#vercel) | ihr nichts betreiben wollt; schnellster Weg | Entra-Registrierung, und dass eine neue Person nach ihrer ersten Anmeldung
| [Docker](#deployment-mit-docker) | es in eure eigene Infrastruktur soll | eine `profiles`-Zeile braucht (siehe [docs/entra-sso.md](docs/entra-sso.md)).
**In beiden Fällen gleich:** die Umgebungsvariablen aus [Abschnitt 1](#1-env-anlegen),
die Umleitungs-URI in der Entra-Registrierung, und dass eine neue Person nach
ihrer ersten Anmeldung eine `profiles`-Zeile braucht (siehe
[docs/entra-sso.md](docs/entra-sso.md)).
## Vercel
Das Repository liegt auf `git.elycon.solutions` — einem selbst betriebenen
Git. **Vercels Git-Anbindung kann nur GitHub, GitLab und Bitbucket**, dieses
Repository lässt sich dort also nicht verknüpfen. Zwei Möglichkeiten:
### a) Von der Arbeitsstation ausrollen (ohne GitHub)
```bash
npx vercel login
npx vercel link
npx vercel --prod
```
Funktioniert mit jedem Repository. Der Preis: kein automatisches Ausrollen
bei einem Push — jede Veröffentlichung ist ein bewusster Befehl. Für zwei
Personen ist das eher Vorteil als Nachteil.
### b) Zusätzlich nach GitHub spiegeln
```bash
git remote add github git@github.com:<konto>/alpenwerk-hr.git
git push github feat/sap-om-org-model
```
Danach das GitHub-Repository in Vercel verbinden. Ab dann rollt jeder Push
aus. Zwei Fernziele bedeuten aber auch: beide müssen gepflegt werden.
### Danach
1. **Umgebungsvariablen** im Vercel-Projekt setzen (Settings → Environment
Variables), dieselben wie in [Abschnitt 1](#1-env-anlegen). `AUTH_URL` ist
nicht nötig, Vercel setzt den Host selbst.
2. **Umleitungs-URI** in der Entra-Registrierung ergänzen:
`https://<projekt>.vercel.app/api/auth/callback/microsoft-entra-id`
3. Der nächtliche Lauf ist über `vercel.json` bereits eingerichtet.
Zwei Eigenheiten der Plattform, die im Code berücksichtigt sind:
`output: "standalone"` entfällt dort automatisch (Vercel baut selbst), und
`/api/import` ist auf 60 Sekunden begrenzt — die Obergrenze des kostenlosen
Tarifs. Im Pro-Tarif liessen sich 300 setzen, falls eine Importdatei mit
vielen tausend Zeilen ansteht.
Der Verbindungspool passt zu serverlosen Aufrufen, **weil** `DATABASE_URL`
auf den Transaktions-Modus zeigt (Port 6543). Mit dem Sitzungs-Modus wären
die 15 Verbindungen des Tarifs nach wenigen gleichzeitigen Aufrufen
verbraucht.
## Deployment mit Docker
Dieser Guide beschreibt, wie die App stattdessen als Docker-Container auf
einem eigenen Linux-Server läuft.
## Was wird containerisiert – und was nicht ## Was wird containerisiert – und was nicht
@@ -84,12 +27,10 @@ einem eigenen Linux-Server läuft.
- **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über - **Ebenfalls nicht containerisiert:** die Anmeldung. Sie läuft über
Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt Microsoft Entra ID; die App hält nur das Sitzungscookie (Auth.js). Es gibt
keinen Anmeldedienst, der mit ausgerollt werden müsste. keinen Anmeldedienst, der mit ausgerollt werden müsste.
- **Ersetzt:** der Vercel-Cron-Job aus `vercel.json` (täglich 03:00 Uhr, - **Der nächtliche Lauf** ist ein eigener Container (`cron`): täglich 03:00
ruft `/api/cron/apply-pending-changes` auf, um fällige Versetzungen/ Uhr ruft er `/api/cron/apply-pending-changes` auf, um fällige Versetzungen,
Beförderungen/Karenz/Reorg-Änderungen zu übernehmen). Da es außerhalb von Beförderungen, Karenz- und Reorg-Änderungen zu übernehmen. Ausgewiesen wird
Vercel kein Vercel-Cron gibt, übernimmt das im `docker-compose.yml` der Aufruf über `CRON_SECRET` — es gibt dabei keine angemeldete Person.
enthaltene `cron`-Sidecar-Container diese Aufgabe mit demselben Schema
und demselben Bearer-Secret, das die Route bereits erwartet.
## Betrieb im Firmennetz — zwei Dinge vorab ## Betrieb im Firmennetz — zwei Dinge vorab
@@ -103,12 +44,13 @@ nichts, ausgehend zwingend:
| Ziel | Wofür | Ohne das | | 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 | | `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 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 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- abgeschotteten Netz, braucht es dafür einen Proxy.
Instanz im selben Netz statt Supabase.
Die Datenbank taucht hier nicht mehr auf: sie läuft als Container daneben, im
selben Compose-Netz. Nach aussen geht dafür nichts.
**2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für **2. HTTPS ist Pflicht, auch intern.** Entra ID akzeptiert `http` nur für
`localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein `localhost`. Der praktikable Weg ohne öffentliche Erreichbarkeit: ein
@@ -126,11 +68,9 @@ bei zwanzig nicht.
## Voraussetzungen ## Voraussetzungen
- Docker + Docker Compose (v2, das im Docker Desktop/Docker Engine - Docker + Docker Compose (v2, das in Docker Engine enthaltene
enthaltene `docker compose`) auf dem Zielserver. `docker compose`) auf dem Zielserver. Sonst nichts — weder Node noch psql;
- Ein bestehendes Supabase-Projekt mit den Migrationen aus was gebraucht wird, kommt aus Containern.
`supabase/migrations/` bereits eingespielt (`supabase db push` bzw. wie
bisher).
## 1. `.env` anlegen ## 1. `.env` anlegen
@@ -142,8 +82,10 @@ Werte eintragen:
| Variable | Woher | | Variable | Woher |
|---|---| |---|---|
| `DATABASE_URL` | Verbindungsstring der PostgreSQL-Instanz. Die Rolle darf **kein** `BYPASSRLS` haben; hinter einem Pooler den **Transaktions-Modus** (bei Supabase Port 6543) | | `POSTGRES_PASSWORD` | selbst erzeugen: `openssl rand -base64 24` — das des Verwalters |
| `DATABASE_SSL` | nur setzen (`false`), wenn die Datenbank ohne TLS läuft | | `APP_DB_PASSWORD` | selbst erzeugen — das der Anwendungsrolle `alpenwerk_app` |
| `DATABASE_URL` | `postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk`. Die Rolle darf **kein** `BYPASSRLS` haben — `deploy/db-init` legt sie genau so an |
| `DATABASE_SSL` | `false` im Compose-Netz; die Verbindung verlässt den Server nicht |
| `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` | | `AUTH_SECRET` | selbst generieren: `openssl rand -base64 32` |
| `AUTH_MICROSOFT_ENTRA_ID_ID` | Entra-Portal → App-Registrierung → Übersicht | | `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_SECRET` | Entra-Portal → Zertifikate & Geheimnisse (nur einmal sichtbar!) |

View File

@@ -110,12 +110,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
`/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte `/api/cron/apply-pending-changes` wendet wirksam gewordene, zukunftsdatierte
Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`). Änderungen an (`pending_org_changes` → `apply_due_pending_changes()`).
- **Auf Vercel:** `vercel.json` definiert den täglichen Schedule; Vercel Cron - Den Zeitplan hält der `cron`-Container aus `docker-compose.yml`: täglich
sendet `Authorization: Bearer <CRON_SECRET>` automatisch, wenn 03:00 Uhr, mit `Authorization: Bearer <CRON_SECRET>`.
`CRON_SECRET` in den Projekt-Env-Vars gesetzt ist.
- **Außerhalb von Vercel (Docker):** kein Vercel Cron verfügbar — siehe
[`DEPLOYMENT.md`](DEPLOYMENT.md) für den Cron-Sidecar-Container, der
denselben Endpoint mit demselben Schema aufruft.
- Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit - Fehlt `CRON_SECRET` oder stimmt der Header nicht, antwortet die Route mit
`401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`). `401` (nicht `500` — bewusst, siehe `tests/unit/security.test.ts`).
@@ -140,10 +136,8 @@ selbst spricht. Ein Docker-Abbild ist damit umgebungsneutral: einmal gebaut,
## Deployment ## Deployment
Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md) für Docker-basiertes Deployment Siehe [`DEPLOYMENT.md`](DEPLOYMENT.md): Anwendung und Datenbank als Container,
(Dockerfile, docker-compose.yml, Reverse-Proxy/TLS, Cron-Ersatz, Updates). Reverse-Proxy/TLS, der nächtliche Lauf, Migrationen und Updates.
Für Vercel: `vercel.json` ist bereits vorhanden; Env-Vars im
Vercel-Projekt setzen (siehe oben).
## Known TODOs vor Produktivbetrieb ## Known TODOs vor Produktivbetrieb

View File

@@ -21,8 +21,8 @@ function pageHref(params: SearchParams, page: number): string {
} }
// Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and // Pinned to Vienna and built once: audit_log.occurred_at is a timestamptz, and
// an unpinned formatter renders it in the *server's* zone — UTC in Docker and // an unpinned formatter renders it in the *server's* zone — UTC in the
// on Vercel — so every entry would read an hour or two early for the people // container — so every entry would read an hour or two early for the people
// the log is for. // the log is for.
const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", { const dateTimeFormatter = new Intl.DateTimeFormat("de-AT", {
day: "2-digit", day: "2-digit",

View File

@@ -4,10 +4,11 @@ import { callFunction } from "@/lib/db/rpc";
// Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten // Applies effective-dated changes (Versetzung/Beförderung/Karenz/Reorg/Daten
// ändern with a future "Wirksam ab" date) once their date has arrived — see // ändern with a future "Wirksam ab" date) once their date has arrived — see
// apply_due_pending_changes() in supabase/migrations. Runs as a Vercel Cron // apply_due_pending_changes() in supabase/migrations.
// job (see vercel.json), not on behalf of any HR user, so it authenticates //
// via a shared secret rather than a Supabase session and uses the // Gerufen wird das vom `cron`-Dienst aus docker-compose.yml, täglich um 03:00.
// service-role client (the one legitimate server-only use case for it). // Nicht im Namen einer HR-Person: es gibt keine angemeldete Sitzung, deshalb
// weist sich der Aufruf mit einem gemeinsamen Geheimnis aus (CRON_SECRET).
export async function GET(request: NextRequest) { export async function GET(request: NextRequest) {
const authHeader = request.headers.get("authorization"); const authHeader = request.headers.get("authorization");
if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) { if (!process.env.CRON_SECRET || authHeader !== `Bearer ${process.env.CRON_SECRET}`) {

View File

@@ -25,10 +25,10 @@ class Rueckabwicklung extends Error {
} }
} }
// 60 Sekunden, weil das die Obergrenze im kostenlosen Vercel-Tarif ist — // Im eigenen Container wirkt diese Angabe nicht — sie richtet sich an
// ein höherer Wert lässt sich dort nicht ausrollen. Auf einem eigenen Server // serverlose Plattformen, die einen Aufruf nach Ablauf abschneiden. Sie bleibt
// gilt die Angabe ohnehin nicht, und im Pro-Tarif liesse sie sich auf 300 // als Absichtserklärung stehen: ein Import, der länger als eine Minute
// heben, falls eine Datei mit vielen tausend Zeilen ansteht. // braucht, ist einer, der in Teilen laufen sollte.
export const maxDuration = 60; export const maxDuration = 60;
type Antwort = { type Antwort = {

View File

@@ -100,9 +100,10 @@ services:
env_file: env_file:
- .env - .env
# Replaces the Vercel Cron job from vercel.json (not available outside # Der nächtliche Lauf: ruft täglich um 03:00 Uhr
# Vercel): calls the same endpoint on the same daily schedule using the # /api/cron/apply-pending-changes auf, damit fällig gewordene Versetzungen,
# same bearer-secret auth the route already expects. # Beförderungen, Karenzen und Reorganisationen wirksam werden. Ausgewiesen
# über CRON_SECRET — es gibt dabei keine angemeldete Person.
cron: cron:
image: alpine:3.20 image: alpine:3.20
restart: unless-stopped restart: unless-stopped

View File

@@ -99,10 +99,9 @@ Ein einziges Rollenmodell, kein Mehrfach-Rollen-System:
Funktion, deren Ausführungsrecht explizit auf `service_role` beschränkt ist Funktion, deren Ausführungsrecht explizit auf `service_role` beschränkt ist
(`revoke ... from public, anon, authenticated; grant ... to service_role`). (`revoke ... from public, anon, authenticated; grant ... to service_role`).
Sie wird von `app/api/cron/apply-pending-changes/route.ts` aufgerufen — Sie wird von `app/api/cron/apply-pending-changes/route.ts` aufgerufen —
täglich per Vercel Cron (`vercel.json`), außerhalb von Vercel per täglich vom `cron`-Container aus `docker-compose.yml`. Die Route selbst
Ersatz-Scheduler (siehe `DEPLOYMENT.md`, Docker-Cron-Sidecar). Die Route authentifiziert per `CRON_SECRET`-Bearer-Token, nicht über eine Sitzung — es
selbst authentifiziert per `CRON_SECRET`-Bearer-Token, nicht per gibt keine anfragende Person, nur den Zeitplan.
Supabase-Session — es gibt keine anfragende Person, nur den Scheduler.
Idempotenz: `pending_org_changes.status` läuft `pending` → `applied` (oder Idempotenz: `pending_org_changes.status` läuft `pending` → `applied` (oder
`cancelled` bei Reorg-Undo); die Auswahl-Query filtert immer auf `cancelled` bei Reorg-Undo); die Auswahl-Query filtert immer auf

View File

@@ -1,7 +1,7 @@
// This app stores dates as date-only strings ("YYYY-MM-DD") and timestamps as // This app stores dates as date-only strings ("YYYY-MM-DD") and timestamps as
// timestamptz, and is used from a single timezone. Every conversion here is // timestamptz, and is used from a single timezone. Every conversion here is
// pinned to Europe/Vienna rather than the runtime's zone: the server renders // pinned to Europe/Vienna rather than the runtime's zone: the server renders
// in UTC (Docker/Vercel) while the browser renders in Vienna, so an unpinned // in UTC (im Container) while the browser renders in Vienna, so an unpinned
// formatter produces a different day on each side — wrong dates for the user // formatter produces a different day on each side — wrong dates for the user
// near midnight, and a React hydration mismatch. // near midnight, and a React hydration mismatch.
const TIMEZONE = "Europe/Vienna"; const TIMEZONE = "Europe/Vienna";

View File

@@ -46,10 +46,9 @@ const nextConfig: NextConfig = {
// Emits a self-contained .next/standalone server (only the deps actually // Emits a self-contained .next/standalone server (only the deps actually
// used at runtime, no full node_modules) — what the Dockerfile copies in. // used at runtime, no full node_modules) — what the Dockerfile copies in.
// //
// Auf Vercel ist das falsch: dort baut die Plattform selbst und erwartet // Ohne Ausnahme: die Anwendung läuft in einem Container auf einem eigenen
// die übliche Ausgabe. `VERCEL` setzt sie in jeder Baustrecke, die Angabe // Server, und dort ist das der richtige Ausgabemodus.
// entfällt dort also von selbst — und der Docker-Weg bleibt unberührt. output: "standalone",
output: process.env.VERCEL ? undefined : "standalone",
// Baseline security headers (clickjacking, MIME-sniffing, referrer leakage, // Baseline security headers (clickjacking, MIME-sniffing, referrer leakage,
// browser feature access) plus the report-only CSP described above. // browser feature access) plus the report-only CSP described above.
async headers() { async headers() {

View File

@@ -1,8 +0,0 @@
{
"crons": [
{
"path": "/api/cron/apply-pending-changes",
"schedule": "0 3 * * *"
}
]
}