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:
47
.env.example
47
.env.example
@@ -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
|
||||
|
||||
16
.github/workflows/deploy.yml
vendored
16
.github/workflows/deploy.yml
vendored
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
82
deploy/db-init/00-rollen-und-erweiterungen.sql
Normal file
82
deploy/db-init/00-rollen-und-erweiterungen.sql
Normal 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;
|
||||
56
deploy/db-init/01-auth-attrappe.sql
Normal file
56
deploy/db-init/01-auth-attrappe.sql
Normal 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;
|
||||
@@ -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:
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
77
scripts/umzug-von-supabase.sh
Normal file
77
scripts/umzug-von-supabase.sh
Normal 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."
|
||||
@@ -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();
|
||||
Reference in New Issue
Block a user