Files
alpenwerk-hr/docker-compose.yml
Maximilian Stubhan 77d9a95f7f
Some checks failed
CI / Lint, Typen, Tests, Build (push) Failing after 5m25s
CI / Integrationstests (echtes Postgres) (push) Failing after 5m9s
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>
2026-08-20 16:34:08 +02:00

125 lines
5.0 KiB
YAML

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
# Umgebung dasselbe.
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,
# und ein Fehler in der Firewall genügte.
ports:
- "127.0.0.1:3000:3000"
env_file:
- .env
# Replaces the Vercel Cron job from vercel.json (not available outside
# Vercel): calls the same endpoint on the same daily schedule using the
# same bearer-secret auth the route already expects.
cron:
image: alpine:3.20
restart: unless-stopped
depends_on:
app:
condition: service_healthy
env_file:
- .env
entrypoint: ["/bin/sh", "-c"]
command:
- |
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: