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>
83 lines
4.1 KiB
SQL
83 lines
4.1 KiB
SQL
-- Grundlage für einen nackten PostgreSQL-Container.
|
|
--
|
|
-- Läuft einmal, beim allerersten Start des `db`-Dienstes: das offizielle
|
|
-- Postgres-Abbild führt alles unter /docker-entrypoint-initdb.d aus, solange
|
|
-- das Datenverzeichnis noch leer ist. Bei jedem weiteren Start passiert hier
|
|
-- nichts mehr — Änderungen daran wirken also nur auf einem frischen Band.
|
|
--
|
|
-- Was hier steht, sind die Voraussetzungen, die auf Supabase von der Plattform
|
|
-- kamen und die ein eigener Server selbst mitbringen muss.
|
|
|
|
-- Das Passwort der Anwendungsrolle kommt aus der Umgebung, damit es nicht in
|
|
-- einer Datei im Repository steht. `\set` mit Backticks ist psql-eigene
|
|
-- Ersetzung — sie läuft, bevor der Server die Zeile sieht.
|
|
\set app_passwort `echo "$APP_DB_PASSWORD"`
|
|
select set_config('init.app_passwort', :'app_passwort', false);
|
|
|
|
do $$
|
|
declare
|
|
v_passwort text := current_setting('init.app_passwort', true);
|
|
begin
|
|
-- Lieber hier laut scheitern als eine Rolle mit leerem Passwort anlegen.
|
|
-- Der Container käme sonst hoch, die Anwendung verbände sich nicht, und der
|
|
-- Grund stünde nirgends.
|
|
if coalesce(v_passwort, '') = '' then
|
|
raise exception 'APP_DB_PASSWORD ist nicht gesetzt — ohne Passwort keine Anwendungsrolle.';
|
|
end if;
|
|
|
|
-- ── Die Rolle, mit der die Anwendung arbeitet ───────────────────────
|
|
--
|
|
-- **Ohne BYPASSRLS, und das ist der Punkt.** Die Zugriffsrechte dieser
|
|
-- Anwendung liegen in RLS-Policies; fehlt der Sitzungskontext, sollen sie
|
|
-- nichts zurückgeben statt alles. Eine Rolle, die RLS umgeht, machte die
|
|
-- ganze Absicherung wirkungslos — und niemand würde es merken, weil alles
|
|
-- weiter funktioniert, nur eben zu viel.
|
|
if not exists (select 1 from pg_roles where rolname = 'alpenwerk_app') then
|
|
execute format('create role alpenwerk_app login password %L', v_passwort);
|
|
else
|
|
execute format('alter role alpenwerk_app with login password %L', v_passwort);
|
|
end if;
|
|
execute 'alter role alpenwerk_app with nobypassrls';
|
|
|
|
-- ── Rollen, die es auf Supabase gab ─────────────────────────────────
|
|
--
|
|
-- `anon`, `authenticated` und `service_role` gehören zu Supabase, nicht zu
|
|
-- dieser Anwendung. Keine einzige Policy nennt sie — sie tragen nur Rechte,
|
|
-- die die Plattform pauschal vergeben hat.
|
|
--
|
|
-- Angelegt werden sie trotzdem, und zwar **ohne Anmelderecht**: ein
|
|
-- Datenabzug aus dem alten Bestand enthält `grant`-Anweisungen auf diese
|
|
-- Namen, und ohne die Rollen bricht das Einspielen ab. Anmelden kann sich
|
|
-- damit niemand, sie sind reine Platzhalter für die Wiederherstellung.
|
|
if not exists (select 1 from pg_roles where rolname = 'anon') then
|
|
execute 'create role anon nologin';
|
|
end if;
|
|
if not exists (select 1 from pg_roles where rolname = 'authenticated') then
|
|
execute 'create role authenticated nologin';
|
|
end if;
|
|
if not exists (select 1 from pg_roles where rolname = 'service_role') then
|
|
execute 'create role service_role nologin';
|
|
end if;
|
|
end
|
|
$$;
|
|
|
|
select set_config('init.app_passwort', '', false);
|
|
|
|
-- ── Erweiterungen ─────────────────────────────────────────────────────
|
|
--
|
|
-- Nur diese beiden werden gebraucht. `uuid-ossp` stand auf Supabase zwar
|
|
-- bereit, wird aber nirgends benutzt (keine Spaltenvorgabe, keine Funktion
|
|
-- ruft uuid_generate_*); die Kennungen kommen aus gen_random_uuid().
|
|
--
|
|
-- Beide liegen in `public`, nicht in einem eigenen `extensions`-Schema: der
|
|
-- search_path der Anwendung und aller SECURITY-DEFINER-Funktionen ist auf
|
|
-- `public, pg_temp` festgenagelt, ein anderes Schema wäre dort unsichtbar.
|
|
create extension if not exists pgcrypto;
|
|
create extension if not exists pg_trgm;
|
|
|
|
-- ── Rechte am Schema ──────────────────────────────────────────────────
|
|
--
|
|
-- Die Migrationen vergeben ihre Tabellenrechte selbst. Was sie voraussetzen,
|
|
-- ist das Recht, das Schema überhaupt zu betreten.
|
|
grant usage on schema public to alpenwerk_app;
|