Run the database in a container of our own
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m25s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m9s

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>
This commit is contained in:
2026-08-20 16:34:08 +02:00
parent d574d3c9d6
commit 77d9a95f7f
9 changed files with 494 additions and 29 deletions

View File

@@ -69,12 +69,18 @@ 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.
- **Containerisiert:** die Next.js-App (`Dockerfile`) **und die Datenbank**
(Dienst `db`, `postgres:17-alpine`). Beide zusammen in
`docker-compose.yml`; die Daten liegen im benannten Volume `db-daten`.
- **Nicht mehr Supabase.** Die Anwendung sprach ohnehin unmittelbar mit
PostgreSQL — Supabase war nur der Betreiber. Was die Plattform beisteuerte,
bringt jetzt `deploy/db-init/` mit: die Anwendungsrolle ohne BYPASSRLS, die
zwei benutzten Erweiterungen und eine Attrappe des `auth`-Schemas, die nur
die alten Migrationen von Juni brauchen. Der Umzug steht in
[Abschnitt 3a](#3a-umzug-von-supabase).
- Läuft die Datenbank woanders (Azure Flexible Server, RDS, eigenes Blech),
genügt es, `DATABASE_URL` dorthin zeigen zu lassen und den `db`-Dienst nicht
zu starten. Die Anwendung merkt keinen Unterschied.
- **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.
@@ -190,6 +196,64 @@ 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.
### Bei einer leeren Datenbank
Der `db`-Container legt beim ersten Start Rollen und Erweiterungen an; das
Schema kommt danach aus den Migrationen:
```bash
docker compose up -d db
docker compose run --rm migrate # legt Tabellen, Funktionen, Policies an
docker compose up -d --build
```
`migrate` steht im Profil `tools` und läuft bei `docker compose up` nicht mit.
Auf dem Host wird dafür weder Node noch psql gebraucht — nur Docker.
## 3a. Umzug von Supabase
Einmalig, wenn der Bestand noch bei Supabase liegt:
```bash
SUPABASE_DB_URL='postgresql://postgres:<passwort>@<projekt>.supabase.com:5432/postgres' \
./scripts/umzug-von-supabase.sh
```
Was das Skript tut und warum:
1. **Schema aus den Migrationen**, nicht aus einem Abzug. Nachgewiesen ist,
dass alle Migrationen auf einer leeren Datenbank durchlaufen und dabei
Spalte für Spalte, Index für Index, Policy für Policy dasselbe ergeben wie
die gewachsene Produktion. Der Weg hat zwei Vorteile: die Buchführung
stimmt danach von selbst, und Supabase-eigene Rechte und Eigentümer kommen
gar nicht erst mit.
2. **Nur die Daten** werden abgezogen (`pg_dump --data-only
--disable-triggers`). Ohne `--disable-triggers` stolpert jede
Fremdschlüsselprüfung über die Ladereihenfolge. RLS steht nicht im Weg —
keine der 19 Tabellen hat `FORCE ROW LEVEL SECURITY`, der Eigentümer
schreibt also durch.
3. Am Ende werden die Zeilenzahlen ausgegeben. **Mit denen bei Supabase
vergleichen**, bevor irgendetwas abgeschaltet wird.
Der Abzug bleibt als Datei liegen. Erst löschen, wenn die Anwendung gegen die
neue Datenbank nachweislich läuft.
Danach in `.env`:
```
DATABASE_URL=postgresql://alpenwerk_app:<APP_DB_PASSWORD>@db:5432/alpenwerk
DATABASE_SSL=false
```
### Was am `auth`-Schema übrigbleibt
`deploy/db-init/01-auth-attrappe.sql` legt ein leeres `auth`-Schema an. Es wird
im Betrieb **nicht** gebraucht: kein Fremdschlüssel, keine Policy, keine
Spaltenvorgabe verweist darauf (geprüft). Gebraucht wird es nur beim Abspielen
der Migrationen von Juni 2026, die damals noch an `auth.users` hingen. Die
Alternative wäre, jene Dateien umzuschreiben — also Geschichte zu fälschen: sie
beschreiben, was damals galt.
## 4. Reverse Proxy + HTTPS
Next.js selbst sollte laut den offiziellen Docs **nicht** direkt exponiert