Supabase was only ever the host: the application has talked to PostgreSQL
directly through pg/Kysely for a while. So the move is mostly about
supplying what the platform used to supply.
Proved before building anything. All 65 migrations replay onto an empty
database, and the result matches production exactly — 183 columns, 25
policies, 68 indexes, 84 constraints, identical sets, no diff. The only
function missing from the rebuild turned out to matter, see below.
What the platform supplied, deploy/db-init now does:
- alpenwerk_app, explicitly NOBYPASSRLS. The whole access model is 21
RLS policies; a role that bypasses them would leave everything
working while showing too much, and nobody would notice.
- pgcrypto and pg_trgm. uuid-ossp was available on Supabase but is
used nowhere — no column default, no function calls uuid_generate_*.
- anon, authenticated and service_role as NOLOGIN placeholders. No
policy names them; they only carry grants the platform handed out,
and a data dump referencing them would fail to restore without them.
- A stub `auth` schema. The end state needs none of it — checked: no
foreign key, no policy, no column default refers to it. The June
2026 migrations do, and rewriting those would be falsifying history;
they describe what was true then.
The gap the comparison found: rls_auto_enable() and the ensure_rls event
trigger existed only in the running database, created by hand, in no
migration. That is the net which forces RLS on every newly created
table — the reason a forgotten policy yields an empty table instead of
an open one. A rebuild from migrations would silently not have had it:
everything works, and the next new table is unprotected. Now a migration
(20260819100000), verified by creating a table on the rebuild and
confirming RLS came on by itself.
Data moves separately, via scripts/umzug-von-supabase.sh: schema from
the migrations, then pg_dump --data-only --disable-triggers for the rows.
Without --disable-triggers every foreign key trips over load order. RLS
does not interfere — none of the 19 tables uses FORCE ROW LEVEL
SECURITY, so the owner writes through. The dump is deliberately left on
disk afterwards.
psql and node come from two `tools`-profile services rather than being
installed on the host, so the server needs nothing but Docker. The db
service publishes no port at all — reachable only inside the compose
network.
SUPABASE_DB_URL is renamed MIGRATE_DATABASE_URL, since after this it
describes something else entirely; the old name still works so existing
.env files keep running. Both were exercised, as was the error when
neither is set.
The deploy workflow is set to manual-only. Its preconditions were never
met — no secrets, and whether the job container can reach the host's
Docker daemon is untested — and failing on every push teaches people to
ignore red runs. It also needs updating for the new database service
before it could work at all.
Not verified: none of this has run in an actual container. There is no
Docker daemon on this machine. What is verified is the part that
decides whether it can work — the schema, on a real empty database.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
64 lines
3.0 KiB
Plaintext
64 lines
3.0 KiB
Plaintext
# ── Datenbank ────────────────────────────────────────────────────────
|
|
#
|
|
# Die Datenbank läuft als eigener Container (Dienst `db` in
|
|
# docker-compose.yml). Diese drei Werte richten ihn ein:
|
|
#
|
|
# POSTGRES_PASSWORD das des Verwalters (postgres) — nur für Migrationen,
|
|
# Sicherungen und Wartung
|
|
# APP_DB_PASSWORD das der Anwendungsrolle alpenwerk_app
|
|
# POSTGRES_DB Name der Datenbank; Vorgabe alpenwerk
|
|
#
|
|
# Beide Passwörter selbst erzeugen: `openssl rand -base64 24`
|
|
#
|
|
# Sie werden nur beim **allerersten** Start ausgewertet, solange das
|
|
# Datenverzeichnis leer ist. Ein späterer Wechsel braucht ein
|
|
# `alter role … password …` in der laufenden Datenbank.
|
|
POSTGRES_PASSWORD=
|
|
APP_DB_PASSWORD=
|
|
POSTGRES_DB=alpenwerk
|
|
POSTGRES_USER=postgres
|
|
|
|
# Womit die Anwendung sich verbindet. Im Compose-Netz heisst die Datenbank
|
|
# `db`; von aussen ist sie nicht erreichbar, es gibt bewusst keinen Port.
|
|
#
|
|
# Die Rolle in diesem String darf KEIN BYPASSRLS haben: fehlt der
|
|
# Sitzungskontext, sollen die Policies nichts zurückgeben statt alles.
|
|
# `alpenwerk_app` wird in deploy/db-init genau so angelegt.
|
|
DATABASE_URL=postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk
|
|
# Im Compose-Netz läuft Postgres ohne TLS — die Verbindung verlässt den
|
|
# Server nicht. Bei einer Datenbank ausserhalb diesen Wert entfernen.
|
|
DATABASE_SSL=false
|
|
# Verbindungen im Pool; Vorgabe 10.
|
|
DATABASE_POOL_MAX=
|
|
|
|
# Der **direkte** Zugang für Migrationen (scripts/migrate.mjs). Als Verwalter,
|
|
# weil Migrationen Schemaänderungen vornehmen, die die Anwendungsrolle nicht
|
|
# darf. Bei Betrieb über docker compose wird er nicht gebraucht — der Dienst
|
|
# `migrate` setzt ihn selbst aus POSTGRES_PASSWORD zusammen.
|
|
MIGRATE_DATABASE_URL=
|
|
|
|
# ── Anmeldung (Auth.js + Microsoft Entra ID) ─────────────────────────
|
|
# Schlüssel, mit dem das Sitzungscookie signiert und verschlüsselt wird.
|
|
# Erzeugen mit `npx auth secret` oder `openssl rand -base64 32`. Ein Wechsel
|
|
# meldet alle ab — was im Ernstfall genau das gewünschte Mittel ist.
|
|
AUTH_SECRET=
|
|
|
|
# Aus der Anwendungsregistrierung im Entra-Portal: Anwendungs-ID (Client),
|
|
# ein Geheimnis daraus, und der Aussteller mit der Verzeichnis-ID (Mandant).
|
|
#
|
|
# Der Aussteller darf NICHT auf /common/ stehen bleiben — sonst könnte sich
|
|
# jedes Microsoft-Konto anmelden, auch ein privates.
|
|
AUTH_MICROSOFT_ENTRA_ID_ID=
|
|
AUTH_MICROSOFT_ENTRA_ID_SECRET=
|
|
AUTH_MICROSOFT_ENTRA_ID_ISSUER=https://login.microsoftonline.com/<verzeichnis-id>/v2.0
|
|
|
|
# Nur nötig, wenn die Anwendung hinter einem Reverse Proxy unter einer
|
|
# anderen Adresse erreichbar ist, als sie selbst sieht. Ohne diesen Wert baut
|
|
# Auth.js die Rückruf-Adresse aus den Request-Headern.
|
|
AUTH_URL=
|
|
|
|
# Shared secret Vercel Cron sends as `Authorization: Bearer <value>` when it
|
|
# calls /api/cron/apply-pending-changes (set the same value in the Vercel
|
|
# project's env vars). Generate with e.g. `openssl rand -hex 32`.
|
|
CRON_SECRET=
|