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

@@ -1,23 +1,42 @@
# Direkter PostgreSQL-Zugang. Die Anwendung spricht künftig unmittelbar mit
# der Datenbank statt über eine API-Schicht — damit läuft sie auf jedem
# PostgreSQL ab 15 (Azure Flexible Server, RDS, Cloud SQL, eigenes Blech).
# ── 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.
#
# Hinter einem Verbindungspooler (Supabase Supavisor, PgBouncer) den
# TRANSAKTIONS-Modus nehmen, nicht den Sitzungs-Modus — bei Supabase Port
# 6543 statt 5432. Jede Abfrage dieser Anwendung läuft ohnehin in einer
# Transaktion, und der Sitzungskontext wird transaktionslokal gesetzt; beides
# passt genau dazu. Der Sitzungs-Modus belegt dagegen je Client eine feste
# Verbindung und ist bei Supabase auf 15 begrenzt — danach antwortet die
# Anwendung nur noch mit „max clients reached".
DATABASE_URL=
# Auf "false" setzen, wenn die Datenbank ohne TLS läuft (lokal, CI).
DATABASE_SSL=
# `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

View File

@@ -10,9 +10,21 @@ name: Deploy
# Runner mit dem Label `self-hosted`. Das Repository liegt aber ohnehin auf
# git.elycon.solutions.)
# Nur von Hand auslösbar (Actions → Deploy → „Run workflow").
#
# Lief einmal bei jedem Push auf master. Das ist zurückgenommen: die
# Voraussetzungen dafür sind nicht erfüllt — es sind keine Secrets hinterlegt,
# und ob der Job-Container den Docker-Dienst des Hosts überhaupt erreicht, ist
# ungeprüft. Bei jedem Push zu scheitern erzieht dazu, rote Läufe zu
# übersehen; dann lieber gar nicht starten.
#
# Nach dem Umzug auf den eigenen db-Container stimmt hier ausserdem noch
# nicht alles: die Secrets unten kennen die neuen Werte (POSTGRES_PASSWORD,
# APP_DB_PASSWORD) nicht, und Migrationen laufen jetzt über den Dienst
# `migrate` statt über einen eigenen Node-Schritt. Wer das wieder scharf
# schaltet, muss beides nachziehen.
on:
push:
branches: [master]
workflow_dispatch:
# Zwei Pushes kurz hintereinander sollen nicht zwei Deploys übereinander
# fahren. Der laufende wird **nicht** abgebrochen — mitten im `docker compose

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

View File

@@ -0,0 +1,82 @@
-- 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;

View File

@@ -0,0 +1,56 @@
-- Eine Attrappe des Supabase-Schemas `auth` — nur für die Migrationshistorie.
--
-- ═══ Warum das hier steht, obwohl es niemand mehr braucht ═══
--
-- Der **Endstand** des Schemas ist frei von Supabase: kein Fremdschlüssel aus
-- `public` zeigt nach `auth`, keine Policy ruft `auth.uid()`, keine
-- Spaltenvorgabe nennt es. Geprüft, nicht vermutet.
--
-- Die **Historie** ist es nicht. Die erste Migration von Juni 2026 hängt
-- profiles an `auth.users`, und ein Dutzend Policies dieser Zeit fragen
-- `auth.role() = 'authenticated'`. Abgelöst wurde das erst im August
-- (20260805110000, 20260805140000). Wer die Migrationen von vorne abspielt —
-- die CI tut das bei jedem Lauf, und genau dafür ist sie da —, kommt ohne
-- diese Objekte nicht an der ersten Datei vorbei.
--
-- Die Alternative wäre, die alten Migrationen umzuschreiben. Das hiesse,
-- Geschichte zu fälschen: die Dateien beschreiben, was damals galt, und ein
-- nachträglich geänderter Stand von Juni ist keiner, der je gelaufen ist.
-- Eine Attrappe, die beim Abspielen trägt und danach leer stehenbleibt, ist
-- die ehrlichere Lösung.
--
-- Sie ist bewusst **so dumm wie möglich**: keine Rechte für die
-- Anwendungsrolle, keine Zeilen, keine Anmeldung. `auth.users` bleibt leer,
-- die Fremdschlüssel darauf verschwinden im Lauf der Migrationen von selbst.
create schema if not exists auth;
-- Nur das, worauf die frühen Fremdschlüssel zeigen. Ohne weitere Spalten:
-- geschrieben wurde in diese Tabelle nie, die Anwendung führt ihre Benutzer
-- in `public.app_users`.
create table if not exists auth.users (
id uuid primary key
);
-- Auf Supabase lasen diese beiden aus dem JWT der Anfrage. Hier gibt es keins
-- — und das ist richtig so: der Sitzungskontext dieser Anwendung steht in
-- `app.user_id` und wird von withUser() je Transaktion gesetzt (lib/db).
--
-- `app_current_user_id()` ruft `auth.uid()` noch auf, gefasst in ein
-- `exception when undefined_function`. Es liefe also auch ohne dieses Schema;
-- mit ihm liefert es null statt in den Ausnahmezweig zu laufen. Das Ergebnis
-- ist dasselbe.
create or replace function auth.uid() returns uuid language sql stable as $$
select nullif(current_setting('app.user_id', true), '')::uuid
$$;
create or replace function auth.role() returns text language sql stable as $$
select case when nullif(current_setting('app.user_id', true), '') is null
then 'anon' else 'authenticated' end
$$;
-- Niemand ausser dem Eigentümer braucht hier etwas. Die Anwendungsrolle
-- bekommt bewusst kein `usage` — fiele je wieder ein Bezug auf dieses Schema
-- in den laufenden Betrieb, soll er scheitern und nicht stillschweigend
-- funktionieren.
revoke all on schema auth from public;

View File

@@ -1,4 +1,86 @@
services:
# Die Datenbank läuft jetzt hier statt bei Supabase.
#
# Was das Schema von der Plattform brauchte, bringt deploy/db-init mit:
# die Anwendungsrolle ohne BYPASSRLS, zwei Erweiterungen und eine Attrappe
# des `auth`-Schemas für die frühen Migrationen. Nachgewiesen ist, dass
# alle Migrationen auf einer leeren Datenbank durchlaufen und dabei
# Spalte für Spalte dasselbe Schema ergeben wie bisher.
db:
image: postgres:17-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-alpenwerk}
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD fehlt in .env}
# Vom Startskript gelesen; damit steht das Passwort der Anwendungsrolle
# nicht in einer Datei im Repository.
APP_DB_PASSWORD: ${APP_DB_PASSWORD:?APP_DB_PASSWORD fehlt in .env}
# Ohne das legt initdb die Datenbank mit der Locale des Abbilds an;
# Sortierung von Umlauten ist bei einer Mitarbeiterliste keine
# Nebensache.
LANG: de_AT.UTF-8
POSTGRES_INITDB_ARGS: "--locale-provider=icu --icu-locale=de-AT --encoding=UTF8"
volumes:
- db-daten:/var/lib/postgresql/data
# Läuft nur beim allerersten Start, solange das Datenverzeichnis leer
# ist. Spätere Änderungen wirken erst auf einem frischen Band.
- ./deploy/db-init:/docker-entrypoint-initdb.d:ro
# Bewusst **kein** `ports:`. Die Datenbank ist nur im Compose-Netz
# erreichbar, nicht vom Host und erst recht nicht von aussen. Wer von
# aussen heranmuss (Sicherung, Migration), tut das über `docker compose
# exec db` — dann gibt es keinen offenen Port, den jemand vergisst.
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres} -d ${POSTGRES_DB:-alpenwerk}"]
interval: 10s
timeout: 5s
retries: 10
start_period: 30s
# ── Werkzeuge, die nicht mitlaufen ───────────────────────────────────
#
# Beide stehen im Profil `tools` und starten deshalb bei `docker compose
# up` **nicht** mit. Sie werden einzeln gerufen (`docker compose run --rm
# migrate`) und sind der Grund, warum auf dem Host weder Node noch ein
# psql installiert sein muss — nur Docker.
# Spielt ausstehende Migrationen gegen den db-Container ein.
migrate:
profiles: ["tools"]
image: node:22-alpine
working_dir: /repo
volumes:
- .:/repo
environment:
# Der direkte Zugang: im Compose-Netz gibt es keinen Pooler, und
# scripts/migrate.mjs erwartet ohnehin die unmittelbare Verbindung.
MIGRATE_DATABASE_URL: postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD}@db:5432/${POSTGRES_DB:-alpenwerk}
DATABASE_SSL: "false"
depends_on:
db:
condition: service_healthy
# `pg` liegt im Repository; fehlt node_modules auf dem Server, wird es
# einmalig nachgeladen, ohne package.json anzufassen.
entrypoint:
- sh
- -c
- 'test -d node_modules/pg || npm install --no-save --no-audit --no-fund pg@8 >/dev/null; node scripts/migrate.mjs "$@"'
- --
# psql und pg_dump, ohne sie auf dem Host zu installieren.
psql:
profiles: ["tools"]
image: postgres:17-alpine
volumes:
- .:/repo
working_dir: /repo
environment:
PGPASSWORD: ${POSTGRES_PASSWORD}
depends_on:
db:
condition: service_healthy
entrypoint: ["psql", "-v", "ON_ERROR_STOP=1", "-h", "db", "-U", "${POSTGRES_USER:-postgres}", "-d", "${POSTGRES_DB:-alpenwerk}"]
app:
# Ohne Build-Argumente: alles, was die Anwendung braucht — DATABASE_URL,
# AUTH_* — liest sie zur Laufzeit aus .env. Das Abbild ist damit für jede
@@ -6,6 +88,9 @@ services:
build:
context: .
restart: unless-stopped
depends_on:
db:
condition: service_healthy
# Nur auf der Loopback-Adresse, nicht auf allen Schnittstellen. Erreichbar
# ist die App damit ausschliesslich über den Reverse Proxy, der TLS
# beendet — sonst stünde daneben derselbe Dienst unverschlüsselt offen,
@@ -31,3 +116,9 @@ services:
- |
echo "0 3 * * * /bin/sh -c 'wget -q -O- --header=\"Authorization: Bearer \$$CRON_SECRET\" http://app:3000/api/cron/apply-pending-changes >> /var/log/cron.log 2>&1'" > /etc/crontabs/root
crond -f -d 8
volumes:
# Benannt, nicht als Pfad auf dem Host: `docker compose down` lässt es
# stehen, und nur ein ausdrückliches `down -v` löscht es. Der Unterschied
# zwischen „Dienst neu gestartet" und „Personaldaten weg".
db-daten:

View File

@@ -42,15 +42,20 @@ const argumente = new Set(process.argv.slice(2));
const nurZeigen = argumente.has("--dry-run");
const baseline = argumente.has("--baseline");
// SUPABASE_DB_URL ist der direkte Zugang (Port 5432, Sitzungsmodus).
// DATABASE_URL zeigt in dieser Anwendung auf den Transaktions-Pooler (6543),
// und der verträgt kein `create schema` in einer Transaktion mit mehreren
// Anweisungen — Migrationen gehören deshalb über die direkte Verbindung.
const verbindung = process.env.SUPABASE_DB_URL || process.env.MIGRATE_DATABASE_URL;
// Der **direkte** Zugang als Verwalter — nicht DATABASE_URL.
//
// DATABASE_URL gehört der Anwendungsrolle, und die darf bewusst kein Schema
// ändern; ausserdem zeigte sie bei Supabase auf den Transaktions-Pooler, der
// mehrteilige Transaktionen nicht mitmacht.
//
// `SUPABASE_DB_URL` heisst nach dem Umzug auf einen eigenen Container nicht
// mehr, was es ist — der Name bleibt als Rückfallebene, damit bestehende
// .env-Dateien und Arbeitsstationen weiterlaufen.
const verbindung = process.env.MIGRATE_DATABASE_URL || process.env.SUPABASE_DB_URL;
if (!verbindung) {
console.error(
"SUPABASE_DB_URL fehlt. Erwartet wird der **direkte** Postgres-Zugang (bei Supabase Port 5432),\n" +
"nicht der Transaktions-Pooler aus DATABASE_URL."
"MIGRATE_DATABASE_URL fehlt. Erwartet wird der **direkte** Zugang als Verwalter,\n" +
"nicht die Verbindung der Anwendung aus DATABASE_URL."
);
process.exit(1);
}

View File

@@ -0,0 +1,77 @@
#!/usr/bin/env bash
# Holt den Datenbestand von Supabase und spielt ihn in den db-Container.
#
# ═══ Der Ablauf, und warum er so aussieht ═══
#
# Das **Schema** kommt nicht aus dem Abzug, sondern aus den Migrationen:
# scripts/migrate.mjs baut es im Container von Grund auf. Nachgewiesen ist,
# dass dabei Spalte für Spalte, Index für Index, Policy für Policy dasselbe
# herauskommt wie in der Produktion. Der Weg über die Migrationen hat zwei
# Vorteile gegenüber einem Schema-Abzug: die Buchführung stimmt danach von
# selbst, und was Supabase an eigenen Rechten und Eigentümern hineingeschrieben
# hat, kommt gar nicht erst mit.
#
# Übernommen werden nur die **Daten**, mit --disable-triggers: sonst stolpert
# jede Fremdschlüsselprüfung über die Ladereihenfolge. (RLS steht dem nicht im
# Weg — keine der 19 Tabellen hat FORCE ROW LEVEL SECURITY, der Eigentümer
# schreibt also durch.)
#
# Auf dem Host wird **nur Docker** gebraucht: psql und pg_dump kommen aus dem
# Dienst `psql`, Node aus dem Dienst `migrate` — beide im Profil `tools`, das
# bei einem gewöhnlichen `docker compose up` nicht mitläuft.
#
# Aufruf im Deploy-Verzeichnis:
#
# SUPABASE_DB_URL='postgresql://…@…supabase.com:5432/postgres' \
# ./scripts/umzug-von-supabase.sh
#
# Der Abzug bleibt als Datei liegen und wird nicht gelöscht: geht das
# Einspielen schief, ist der Bestand sonst nur noch bei Supabase.
set -euo pipefail
: "${SUPABASE_DB_URL:?SUPABASE_DB_URL fehlt — der direkte Zugang zur alten Datenbank (Supabase: Port 5432)}"
ABZUG="${ABZUG:-supabase-daten-$(date +%Y%m%d-%H%M%S).sql}"
echo "── 1/5 Datenbank starten und abwarten"
docker compose up -d db
docker compose ps db
echo
echo "── 2/5 Schema aus den Migrationen aufbauen"
docker compose run --rm migrate
echo
echo "── 3/5 Daten von Supabase abziehen → ${ABZUG}"
# Läuft im psql-Abbild, damit pg_dump nicht auf dem Host installiert sein muss.
# --entrypoint überschreibt den psql-Aufruf des Dienstes.
docker compose run --rm --no-deps --entrypoint pg_dump psql \
"${SUPABASE_DB_URL}" \
--data-only \
--disable-triggers \
--schema=public \
--no-owner \
--no-privileges \
> "${ABZUG}"
echo " $(wc -l < "${ABZUG}") Zeilen, $(du -h "${ABZUG}" | cut -f1)"
echo
echo "── 4/5 Einspielen"
docker compose run --rm -T psql < "${ABZUG}"
echo
echo "── 5/5 Gegenprobe — Zeilenzahlen hier und dort"
echo " neue Datenbank:"
docker compose run --rm -T psql -qAt -c "
select ' '||table_name||': '||(xpath('/row/c/text()',
query_to_xml('select count(*) as c from public.'||quote_ident(table_name), false, true, '')))[1]::text
from information_schema.tables
where table_schema='public' and table_type='BASE TABLE' order by table_name"
echo
echo "Fertig. Nächster Schritt: DATABASE_URL in .env auf den Container zeigen"
echo "lassen (siehe DEPLOYMENT.md), dann docker compose up -d --build"
echo
echo "Der Abzug bleibt unter ${ABZUG} liegen — erst löschen, wenn die Anwendung"
echo "gegen die neue Datenbank nachweislich läuft."

View File

@@ -0,0 +1,59 @@
-- Das RLS-Sicherheitsnetz gehört ins Repository, nicht nur in die Datenbank.
--
-- `rls_auto_enable()` und der Ereignis-Trigger `ensure_rls` schalten Row Level
-- Security bei jeder neu angelegten Tabelle in `public` sofort ein. Sie sind
-- der Grund, warum eine vergessene Policy nichts preisgibt: ohne RLS wäre eine
-- neue Tabelle für die Anwendungsrolle offen, mit RLS und ohne Policy ist sie
-- leer. Ein Fehler wird so zu einer fehlenden Anzeige statt zu einem Leck.
--
-- **Gefunden beim Umzug von Supabase auf einen eigenen Container.** Beide
-- Objekte existierten nur in der laufenden Datenbank — angelegt von Hand, in
-- keiner Migration. Ein Nachbau aus den Migrationen (die CI tut das bei jedem
-- Lauf, und ein neuer Server sowieso) hätte das Netz stillschweigend nicht
-- gehabt: alles funktioniert, nur die nächste neue Tabelle wäre ungeschützt.
--
-- Der Text ist der aus der Produktion, unverändert übernommen. Auf einer
-- Datenbank, die ihn schon hat, ändert diese Migration nichts.
create or replace function public.rls_auto_enable()
returns event_trigger
language plpgsql
security definer
set search_path to 'public', 'pg_temp'
as $function$
declare
cmd record;
begin
for cmd in
select *
from pg_event_trigger_ddl_commands()
where command_tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
and object_type in ('table', 'partitioned table')
loop
if cmd.schema_name is not null and cmd.schema_name in ('public') then
begin
execute format('alter table if exists %s enable row level security', cmd.object_identity);
raise log 'rls_auto_enable: enabled RLS on %', cmd.object_identity;
exception
-- Bewusst geschluckt: ein Ereignis-Trigger, der wirft, lässt die
-- ganze DDL scheitern. Eine Tabelle, an der das Einschalten nicht
-- klappt, ist ein Fall fürs Protokoll — kein Grund, die Migration
-- abzubrechen, die sie gerade anlegt.
when others then
raise log 'rls_auto_enable: failed to enable RLS on %', cmd.object_identity;
end;
else
raise log 'rls_auto_enable: skip % (Schema %)', cmd.object_identity, cmd.schema_name;
end if;
end loop;
end;
$function$;
-- `create event trigger` kennt kein `if not exists`; deshalb erst weg, dann neu.
-- Auf einer Datenbank ohne den Trigger ist das `drop` folgenlos.
drop event trigger if exists ensure_rls;
create event trigger ensure_rls
on ddl_command_end
when tag in ('CREATE TABLE', 'CREATE TABLE AS', 'SELECT INTO')
execute function public.rls_auto_enable();