Run the database in a container of our own
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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user