Zum Inhalt springen
Infrastruktur & Technologie

Supabase Self-Hosting Runbook: sichere Architektur

Supabase Self-Hosting Runbook: Hetzner-Setup, Docker Compose, Service-Architektur, Secrets, RLS und Backups.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 30 Min. Lesezeit

Supabase selbst zu hosten ist technisch relativ einfach. Supabase sicher und stabil zu betreiben ist deutlich anspruchsvoller.

Der Grund: Supabase ist keine einzelne Datenbank, sondern eine komplette Backend-Plattform. Ein self-hosted Supabase-Stack besteht typischerweise aus mehreren Komponenten: PostgreSQL, PostgREST API, GoTrue Auth, Realtime Server, Storage, Kong API Gateway, Supabase Studio und optional Edge Functions.

Damit betreibt man faktisch eine Backend-Plattform, nicht nur eine Datenbank. Ähnliche Überlegungen gelten auch beim Self-Hosting von Sprachmodellen.

Dieses Runbook beschreibt ein minimales Produktionssetup, das sicher betrieben werden kann und gleichzeitig automatisierbar bleibt. Jeder Schritt enthält eine konkrete Implementierung, eine prüfbare Bedingung und ein Failure Scenario.

Auf einen Blick - Artikel 1 von 6 der DevOps-Runbook-Serie

  • Zwei-Server-Architektur trennt Produktionssystem und Audit-System physisch
  • Docker Compose mit versionsgepinnten Images (kein :latest)
  • Sieben Services: PostgreSQL, PostgREST, GoTrue, Kong, Realtime, Storage, Studio
  • Row Level Security auf allen public-Tabellen als Pflicht
  • Tägliche verschlüsselte Backups mit GPG, extern auf dem Audit-Server gespeichert

Hinweis zum Hoster: Dieses Runbook verwendet Hetzner Cloud (DE) als Infrastruktur-Beispiel, weil Hetzner deutsche Rechenzentren bietet, nicht dem US Cloud Act unterliegt und ein gutes Preis-Leistungs-Verhältnis hat. Die Architekturprinzipien gelten aber hosterunabhängig. Die Hetzner-spezifischen Stellen (vSwitch, Cloud Firewall API, Robot Panel) lassen sich direkt auf andere EU-Anbieter übertragen: OVH vRack (FR), Netcup vLAN (DE), IONOS Cloud (DE), Scaleway Private Networks (FR). Für den brasilianischen Markt eignen sich Locaweb oder Magalu Cloud (beide BR). Wo ein Schritt Hetzner-spezifisch ist, weisen wir darauf hin.

Serien-Inhaltsverzeichnis

Diese Anleitung ist Teil unserer DevOps-Runbook-Serie für moderne self-hosted App-Stacks.

  1. Supabase Self-Hosting Runbook - dieser Artikel
  2. Next.js über Supabase sicher betreiben
  3. Supabase Edge Functions sicher einsetzen
  4. Trigger.dev Background Jobs sicher betreiben
  5. Claude Code als Sicherheitskontrolle im DevOps-Workflow
  6. Security Baseline für den gesamten Stack

Artikel 1 behandelt Infrastruktur, Services und Stack-Konfiguration. Die folgenden Artikel bauen darauf auf.

Zielarchitektur

Ein stabiles Setup trennt mindestens zwei Verantwortungsbereiche.

Internet
   |
   |  HTTPS (443)
   |
Reverse Proxy (Caddy / Nginx / Traefik)
   |
   |  TLS terminiert
   |
Kong API Gateway
   |
   +-- GoTrue         (Auth)
   +-- PostgREST       (API)
   +-- Realtime        (WebSocket)
   +-- Storage         (S3-kompatibler Object Store)
   |
PostgreSQL
   |
   +-- RLS Policies

Parallel dazu läuft ein zweites, getrenntes System:

Audit Server
   |
   +-- Security Checks    (Lynis, Trivy, Port Scans)
   +-- Drift Detection     (Config Diffs gegen Baseline)
   +-- Claude Code Review  (kontextuelle Analyse)
   +-- Monitoring          (Metriken, Alerts)
   +-- Backup Verification (Restore Tests)

Warum diese Trennung notwendig ist: Wenn Produktionssystem und Audit-System identisch sind, kann ein kompromittierter Server gleichzeitig auch seine eigenen Sicherheitschecks manipulieren.

Laut dem Verizon Data Breach Investigations Report 2024 gehen mehr als 60% aller Datenbank-bezogenen Sicherheitsvorfälle auf Fehlkonfigurationen und fehlende Zugriffskontrollen zurück.

Teil A - Infrastruktur-Entscheidungen

Diese Entscheidungen werden einmal getroffen und bilden das Fundament für alles Weitere.

A1 - Infrastruktur trennen: zwei Server

Umsetzung

Mindestens zwei Server betreiben, physisch oder als separate VMs:

supabase-prod     (Hetzner Cloud CX32 oder höher)
audit-runner      (Hetzner Cloud CX22 reicht)

Hetzner-spezifisch: In der Hetzner Cloud Console unter “Servers” zwei separate Instanzen anlegen, beide im selben Projekt und selben Standort (z.B. fsn1). Bei anderen Hostern: zwei VMs in derselben Region/Zone.

supabase-prod trägt den gesamten Supabase-Stack und PostgreSQL. audit-runner trägt Security Checks, Monitoring, Drift Detection und Claude Code Analyse.

Prüfbare Bedingung

# Beide Server müssen separate Hosts sein
ssh supabase-prod hostname
ssh audit-runner hostname

# Erwartung: unterschiedliche Hostnamen und IPs

Failure Scenario

Wenn Audit und Produktion auf demselben Server laufen und ein Angreifer Root-Zugriff erlangt, kann er Logs löschen, Security-Check-Ergebnisse manipulieren und Alerts unterdrücken. Die Kompromittierung bleibt unentdeckt.

A2 - Privates Netzwerk einrichten

Umsetzung

Beide Server kommunizieren intern über ein privates Netzwerk. Supabase-Dienste sind nur über diese internen IPs erreichbar.

Hetzner-spezifisch: Unter “Networks” einen vSwitch oder ein Cloud Network anlegen mit Subnetz 10.0.1.0/24. Beide Server dem Netzwerk zuweisen. Hetzner legt automatisch ein Interface an (typisch ens10). Bei OVH: vRack mit privatem Netzwerk. Bei Netcup: vLAN. Bei IONOS: Cloud Network. Bei Scaleway: Private Networks.

# Auf beiden Servern das private Interface konfigurieren
# /etc/network/interfaces.d/60-private.cfg (Hetzner-spezifisch)

auto ens10
iface ens10 inet static
  address 10.0.1.10/24    # supabase-prod
  # address 10.0.1.11/24  # audit-runner
# Netzwerk aktivieren
systemctl restart networking

# Prüfen
ip addr show ens10
ping 10.0.1.11   # vom prod-Server aus

Prüfbare Bedingung

# Vom audit-runner aus: internes Interface aktiv?
ssh audit-runner "ip addr show ens10 | grep 10.0.1.11"

# PostgreSQL darf nur intern lauschen
ssh supabase-prod "ss -tlnp | grep 5432"

# Erwartung: 10.0.1.10:5432, NICHT 0.0.0.0:5432

Failure Scenario

Wenn PostgreSQL auf 0.0.0.0:5432 lauscht und die Firewall einen Fehler hat, ist die Datenbank direkt aus dem Internet erreichbar. Mit dem service_role Key oder einem schwachen Postgres-Passwort ist die gesamte Datenbank kompromittiert.

A3 - Reverse Proxy mit TLS

Umsetzung

Vor Kong sitzt ein Reverse Proxy, der TLS terminiert und als einziger Service von außen erreichbar ist. Caddy wird hier als Beispiel verwendet, weil es automatisches Let’s Encrypt mitbringt.

# Caddyfile

app.example.com {
    reverse_proxy localhost:8000 {
        header_up X-Forwarded-Proto {scheme}
        header_up X-Real-IP {remote_host}
    }

    header {
        X-Frame-Options "DENY"
        X-Content-Type-Options "nosniff"
        Referrer-Policy "strict-origin-when-cross-origin"
        Strict-Transport-Security "max-age=63072000; includeSubDomains; preload"
        -Server
    }
}

Alternativ mit Nginx:

server {
    listen 443 ssl http2;
    server_name app.example.com;

    ssl_certificate /etc/letsencrypt/live/app.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.example.com/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers HIGH:!aNULL:!MD5;

    location / {
        proxy_pass http://127.0.0.1:8000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;

        # WebSocket Support für Realtime
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Prüfbare Bedingung

# TLS aktiv und korrekt konfiguriert?
curl -I https://app.example.com

# Erwartung: HTTP/2 200, Strict-Transport-Security Header vorhanden

# TLS Version prüfen
openssl s_client -connect app.example.com:443 -tls1_2 </dev/null 2>&1 | grep "Protocol"

# Zertifikat Ablaufdatum
echo | openssl s_client -connect app.example.com:443 2>/dev/null | \
  openssl x509 -noout -enddate

# Erwartung: notAfter mindestens 14 Tage in der Zukunft

# Security Headers vorhanden?
curl -sI https://app.example.com | grep -E \
  "X-Frame-Options|Strict-Transport-Security|X-Content-Type-Options"

Failure Scenario

Ohne TLS laufen Auth-Tokens im Klartext über das Netzwerk. Jeder im selben Netzwerksegment kann sie mitlesen (Man-in-the-Middle). Ohne automatisches Zertifikats-Renewal läuft das Zertifikat nach 90 Tagen ab und die App ist nicht mehr erreichbar.

A4 - Firewall doppelt setzen

Umsetzung

Zwei Ebenen, die sich gegenseitig absichern:

Ebene 1: Cloud Firewall (vor dem Server)

Hetzner-spezifisch: Unter “Firewalls” eine neue Firewall anlegen und beiden Servern zuweisen. Bei OVH: Firewall Network. Bei Netcup: SCP Firewall. Bei IONOS: Cloud Firewall. Bei Scaleway: Security Groups.

# Hetzner Cloud Firewall Regeln für supabase-prod

Inbound:
  TCP 443    von 0.0.0.0/0        (HTTPS)
  TCP 22     von ADMIN_IP/32       (SSH nur von Admin)
  TCP ALL    von 10.0.1.0/24       (internes Netzwerk)

Outbound:
  ALL        nach 0.0.0.0/0        (Updates, DNS, Let's Encrypt)

Ebene 2: Host Firewall (auf dem Server)

# /etc/iptables/rules.v4

*filter
:INPUT DROP [0:0]
:FORWARD DROP [0:0]
:OUTPUT ACCEPT [0:0]

# Loopback
-A INPUT -i lo -j ACCEPT

# Established Connections
-A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT

# SSH nur von Admin IP
-A INPUT -p tcp --dport 22 -s ADMIN_IP -j ACCEPT

# HTTPS
-A INPUT -p tcp --dport 443 -j ACCEPT

# Internes Netzwerk (alle Ports)
-A INPUT -s 10.0.1.0/24 -j ACCEPT

# Alles andere droppen (Default Policy)
COMMIT
# Firewall aktivieren
apt install iptables-persistent
iptables-restore < /etc/iptables/rules.v4

# Baseline speichern für Drift Detection
iptables-save > /root/firewall-baseline.txt

Prüfbare Bedingung

# Von außen: nur 443 offen
nmap -p 22,80,443,5432,8000,9000 app.example.com

# Erwartung: nur 443 open (22 nur von Admin-IP)

# Auf dem Server: Regeln aktiv?
iptables -L -n | grep -c "DROP"

# Erwartung: mindestens 1 (Default DROP Policy)

# Drift Detection: hat sich die Firewall geändert?
iptables-save | diff /root/firewall-baseline.txt -

# Erwartung: keine Abweichungen

Failure Scenario

Eine einzelne Firewall kann falsch konfiguriert werden. Ein iptables -F (flush) auf dem Server öffnet alle Ports, wenn keine Cloud Firewall existiert. Umgekehrt schützt eine Cloud Firewall nicht vor Prozessen die auf dem Server selbst neue Ports öffnen, wenn kein Host Firewall aktiv ist.

A5 - SSH-Zugriff absichern

Umsetzung

# /etc/ssh/sshd_config

PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
AllowUsers deploy
# SSH Key auf dem Server hinterlegen
mkdir -p /home/deploy/.ssh
echo "ssh-ed25519 AAAA... admin@workstation" > /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh

# SSHD neu laden
systemctl reload sshd

Prüfbare Bedingung

# Passwort-Login muss fehlschlagen
ssh -o PasswordAuthentication=yes -o PubkeyAuthentication=no deploy@app.example.com

# Erwartung: Permission denied

# Root-Login muss fehlschlagen
ssh root@app.example.com

# Erwartung: Permission denied

# Konfiguration prüfen
sshd -T | grep -E "passwordauthentication|permitrootlogin"

# Erwartung: passwordauthentication no, permitrootlogin no

Failure Scenario

Passwortbasierte SSH-Zugänge werden kontinuierlich aus dem Internet angegriffen (Brute Force). Ein schwaches Passwort wird typischerweise innerhalb von Stunden gefunden. Root-Login bedeutet, dass ein Angreifer sofort volle Kontrolle über den Server hat.

Teil B - Supabase Services einrichten und absichern

Ein self-hosted Supabase-Stack besteht aus über 10 Services. Jeder hat eigene Environment Variables, eigene Datenbankrollen und eigene Sicherheitsanforderungen. Die offizielle docker-compose.yml von Supabase ist über 400 Zeilen lang und enthält viele Settings, die nicht offensichtlich sicherheitsrelevant sind, es aber trotzdem sind.

Dieser Abschnitt erklärt jeden Service, seine Rolle, seine sicherheitsrelevante Konfiguration und die typischen Fehler beim Setup.

Service-Übersicht

Internet

   │  HTTPS (443)

Reverse Proxy (Caddy/Nginx)     ← Teil A3

Kong (API Gateway)               ← routet zu allen Services

   ├── GoTrue (Auth)              ← /auth/v1/*
   ├── PostgREST (REST API)       ← /rest/v1/*
   ├── Realtime                   ← /realtime/v1/*
   ├── Storage API                ← /storage/v1/*
   ├── Edge Functions             ← /functions/v1/*
   ├── Studio (Dashboard)         ← / (geschützt mit Basic Auth)

   ├── Meta (Postgres-Meta)       ← intern, für Studio
   ├── ImgProxy                   ← intern, für Storage
   ├── Analytics (Logflare)       ← intern, für Logs
   ├── Vector                     ← intern, Log-Pipeline
   └── Supavisor (Pooler)         ← Connection Pooling

PostgreSQL                        ← Datenbank mit Init-Scripts

   └── Rollen: anon, authenticated, service_role,
       authenticator, supabase_admin, supabase_auth_admin,
       supabase_storage_admin

Supabase Service-Übersicht

ServicePortFunktionSicherheitskritische Konfiguration
PostgreSQL5432DatenbankNur internes Interface (10.0.1.10), RLS auf allen public-Tabellen
PostgREST3000REST APINur über Kong erreichbar, JWT-Validierung
GoTrue9999AuthentifizierungJWT-Expiry max. 3600s, Refresh Token Rotation
Kong8000API GatewayEinziger Einstiegspunkt, Rate Limiting
Realtime4000WebSocketRLS-basierte Autorisierung
Storage5000DateispeicherBucket Policies, keine öffentlichen Buckets
Studio3000Admin UINicht extern erreichbar, nur SSH-Tunnel

B0 - Secrets generieren (vor dem ersten Start)

Das muss VOR dem Start aller Services passieren. Die offizielle .env.example enthält Platzhalter. Diese müssen durch generierte Werte ersetzt werden.

# JWT Secret (wird von GoTrue, PostgREST, Realtime, Kong gemeinsam genutzt)
JWT_SECRET=$(openssl rand -base64 32)

# Postgres Passwort
POSTGRES_PASSWORD=$(openssl rand -base64 24)

# Dashboard Passwort (Basic Auth über Kong)
DASHBOARD_PASSWORD=$(openssl rand -base64 16)

# Logflare Tokens
LOGFLARE_PUBLIC_ACCESS_TOKEN=$(openssl rand -hex 16)
LOGFLARE_PRIVATE_ACCESS_TOKEN=$(openssl rand -hex 16)

# MinIO Root Passwort (falls S3 Storage genutzt)
MINIO_ROOT_PASSWORD=$(openssl rand -hex 16)

# Realtime Secret Key Base (min. 64 Zeichen)
SECRET_KEY_BASE=$(openssl rand -base64 48)

# Supavisor Vault Encryption Key
VAULT_ENC_KEY=$(openssl rand -base64 24)

# PG Meta Crypto Key
PG_META_CRYPTO_KEY=$(openssl rand -base64 24)

# Anon Key und Service Role Key (JWTs, generiert mit dem JWT_SECRET)
# Nutze https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys
# oder generiere sie manuell mit jwt.io und dem JWT_SECRET

Sicherheitsregel: Alle Secrets landen in der .env Datei auf dem Server (Rechte 600, nicht im Git). Supabase nutzt ein einziges JWT_SECRET für alle Services. Wenn dieses Secret kompromittiert ist, sind GoTrue, PostgREST und Realtime gleichzeitig betroffen.

Prüfbare Bedingung

# Alle erforderlichen Secrets gesetzt?
for var in JWT_SECRET POSTGRES_PASSWORD ANON_KEY SERVICE_ROLE_KEY \
  DASHBOARD_PASSWORD LOGFLARE_PUBLIC_ACCESS_TOKEN SECRET_KEY_BASE \
  VAULT_ENC_KEY PG_META_CRYPTO_KEY; do
  if grep -q "^${var}=$\|^${var}=your-\|^${var}=change" .env 2>/dev/null; then
    echo "KRITISCH: $var ist nicht gesetzt oder hat Default-Wert"
  fi
done

# Keine identischen Passwörter?
PASSWORDS=$(grep -E "PASSWORD|SECRET|KEY" .env | cut -d= -f2 | sort)
UNIQUE=$(echo "$PASSWORDS" | sort -u)
if [ "$(echo "$PASSWORDS" | wc -l)" != "$(echo "$UNIQUE" | wc -l)" ]; then
  echo "WARNUNG: Einige Secrets haben identische Werte"
fi

Failure Scenario

Default-Secrets aus der .env.example sind öffentlich bekannt. Ein Angreifer kann mit dem Default JWT_SECRET gültige Tokens generieren und erhält vollen Zugriff auf die API. Mit dem Default SERVICE_ROLE_KEY umgeht er zusätzlich alle RLS-Policies.

B1 - PostgreSQL: Datenbank und Rollen

Was dieser Service macht

PostgreSQL ist die zentrale Datenbank. Im Supabase-Kontext hat sie eine besondere Rolle: Sie speichert nicht nur Applikationsdaten, sondern auch Auth-Daten (GoTrue), Realtime-Konfiguration, Storage-Metadaten und Analytics-Logs. Die Init-Scripts erstellen spezielle Schemas und Rollen.

Sicherheitsrelevante Konfiguration

db:
  image: supabase/postgres:15.8.1.085    # Supabase-eigenes Image, Version pinnen
  restart: unless-stopped
  ports:
    - "10.0.1.10:5432:5432"              # NUR internes Interface
  volumes:
    # Init-Scripts (erstellen Schemas, Rollen, Extensions)
    - ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
    - ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
    - ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
    - ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
    - ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
    - ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
    - ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
    # Persistente Daten
    - ./volumes/db/data:/var/lib/postgresql/data:Z
    - db-config:/etc/postgresql-custom
  healthcheck:
    test: ["CMD", "pg_isready", "-U", "postgres", "-h", "localhost"]
    interval: 5s
    timeout: 5s
    retries: 10
  environment:
    POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    POSTGRES_DB: ${POSTGRES_DB}
    JWT_SECRET: ${JWT_SECRET}
    JWT_EXP: ${JWT_EXPIRY}

Die Init-Scripts erstellen folgende Rollen:

postgres              → Superuser (nur für Admin, nie für Services)
anon                  → Unauthentifizierte Requests (über PostgREST)
authenticated         → Authentifizierte Requests (über PostgREST)
service_role          → Umgeht RLS (für Admin-Operationen)
authenticator         → PostgREST nutzt diese Rolle zum Verbinden
supabase_admin        → Interne Admin-Rolle (Realtime, Analytics)
supabase_auth_admin   → GoTrue-spezifisch (auth Schema)
supabase_storage_admin → Storage-spezifisch (storage Schema)

Was schiefgehen kann

Die roles.sql definiert die Grants für jede Rolle. Wenn diese Datei modifiziert wird (z.B. um schnell ein Problem zu lösen), können Rollen zu viele Rechte bekommen. Die anon-Rolle darf nur die Rechte haben, die RLS-Policies explizit erlauben.

Prüfbare Bedingung

# Rollen und ihre Rechte prüfen
docker compose exec -T db psql -U postgres -c \
  "SELECT rolname, rolsuper, rolcreaterole, rolcreatedb, rolcanlogin
   FROM pg_roles
   WHERE rolname IN ('anon','authenticated','service_role','authenticator',
     'supabase_admin','supabase_auth_admin','supabase_storage_admin')
   ORDER BY rolname;"

# anon darf NICHT superuser sein
docker compose exec -T db psql -U postgres -c \
  "SELECT rolname, rolsuper FROM pg_roles WHERE rolname = 'anon' AND rolsuper = true;"
# Erwartung: keine Zeilen

# Postgres lauscht NUR intern
ss -tlnp | grep 5432
# Erwartung: 10.0.1.10:5432, NICHT 0.0.0.0:5432

B2 - Kong: API Gateway und Routing

Was dieser Service macht

Kong ist der zentrale Einstiegspunkt für alle API-Requests. Er routet basierend auf dem URL-Pfad an die richtigen Services, handhabt JWT-Validierung und schützt das Studio-Dashboard mit Basic Auth.

Sicherheitsrelevante Konfiguration

kong:
  image: kong:2.8.1                       # Version pinnen
  restart: unless-stopped
  ports:
    - "127.0.0.1:8000:8000"              # NUR localhost (Reverse Proxy davor)
    - "127.0.0.1:8443:8443"              # HTTPS intern
  volumes:
    - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
  environment:
    KONG_DATABASE: "off"                   # Deklarative Config, keine DB
    KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
    KONG_DNS_ORDER: LAST,A,CNAME
    KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction
    SUPABASE_ANON_KEY: ${ANON_KEY}
    SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
    DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
    DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
  entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'

Die kong.yml definiert das Routing:

/rest/v1/*        → PostgREST (port 3000)    + JWT Validation
/auth/v1/*        → GoTrue (port 9999)       + JWT Validation
/realtime/v1/*    → Realtime (port 4000)      + JWT Validation
/storage/v1/*     → Storage (port 5000)       + JWT Validation
/functions/v1/*   → Edge Functions (port 54321) + JWT Validation (optional)
/                 → Studio (port 3000)        + Basic Auth

Was schiefgehen kann

Kong auf 0.0.0.0:8000 statt 127.0.0.1:8000 bedeutet, dass die API ohne Reverse Proxy (ohne TLS) direkt erreichbar ist. Ein schwaches Dashboard-Passwort gibt über Basic Auth Zugang zum Studio und damit zur gesamten Datenbank. Die kong.yml kann so konfiguriert werden, dass JWT-Validierung für bestimmte Routes deaktiviert ist.

Prüfbare Bedingung

# Kong nur auf localhost?
ss -tlnp | grep 8000
# Erwartung: 127.0.0.1:8000

# Dashboard-Passwort stark genug?
DASH_PW_LEN=$(grep "DASHBOARD_PASSWORD" .env | cut -d= -f2 | wc -c)
[ "$DASH_PW_LEN" -lt 16 ] && echo "WARNUNG: Dashboard-Passwort zu kurz"

# kong.yml: JWT Validation aktiv auf allen API-Routes?
grep -A5 "key-auth" volumes/api/kong.yml | head -20

B3 - GoTrue: Authentifizierung

Was dieser Service macht

GoTrue handhabt User-Registrierung, Login, Magic Links, OAuth, MFA und Token-Ausgabe. Es ist der einzige Service der JWTs ausstellt und hat einen eigenen DB-User (supabase_auth_admin) mit Zugriff auf das auth Schema.

Sicherheitsrelevante Konfiguration

auth:
  image: supabase/gotrue:v2.184.0       # Version pinnen
  restart: unless-stopped
  environment:
    GOTRUE_API_HOST: 0.0.0.0
    GOTRUE_API_PORT: 9999
    API_EXTERNAL_URL: ${API_EXTERNAL_URL}    # https://app.example.com

    # Datenbank (eigener Admin-User)
    GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}

    # JWT
    GOTRUE_JWT_SECRET: ${JWT_SECRET}
    GOTRUE_JWT_EXP: ${JWT_EXPIRY}            # MAXIMAL 3600 (1 Stunde)
    GOTRUE_JWT_AUD: authenticated
    GOTRUE_JWT_ADMIN_ROLES: service_role
    GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated

    # Registrierung
    GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}  # true wenn geschlossene App
    GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
    GOTRUE_MAILER_AUTOCONFIRM: false          # IMMER false in Produktion

    # SMTP (für Magic Links, E-Mail-Bestätigung)
    GOTRUE_SMTP_HOST: ${SMTP_HOST}
    GOTRUE_SMTP_PORT: ${SMTP_PORT}
    GOTRUE_SMTP_USER: ${SMTP_USER}
    GOTRUE_SMTP_PASS: ${SMTP_PASS}
    GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_ADMIN_EMAIL}
    GOTRUE_SMTP_SENDER_NAME: ${SMTP_SENDER_NAME}

    # Mailer URL-Pfade (müssen zum Kong-Routing passen)
    GOTRUE_MAILER_URLPATHS_CONFIRMATION: "/auth/v1/verify"
    GOTRUE_MAILER_URLPATHS_INVITE: "/auth/v1/verify"
    GOTRUE_MAILER_URLPATHS_RECOVERY: "/auth/v1/verify"
    GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: "/auth/v1/verify"

    # Site URL (Redirect nach Auth)
    GOTRUE_SITE_URL: ${SITE_URL}              # URL eurer Next.js App
    GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}

    # Refresh Token Rotation (verhindert Token Reuse)
    GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: true
    GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10

SMTP-Konfiguration: Warum sie sicherheitsrelevant ist

Ohne funktionierenden SMTP-Server können keine Bestätigungs-E-Mails versendet werden. Wenn GOTRUE_MAILER_AUTOCONFIRM: true gesetzt ist, um das zu umgehen, kann sich jeder mit einer beliebigen E-Mail-Adresse registrieren. Das bedeutet: keine Verifizierung der Identität.

Prüfbare Bedingung

# GoTrue Health Check
docker compose exec -T auth wget --no-verbose --tries=1 --spider http://localhost:9999/health

# JWT Expiry nicht über 3600?
grep "JWT_EXPIRY\|JWT_EXP" .env | head -1
# Erwartung: 3600 oder weniger

# Autoconfirm deaktiviert?
grep "AUTOCONFIRM" .env
# Erwartung: false

# SMTP konfiguriert (nicht leer)?
for var in SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS; do
  VAL=$(grep "^${var}=" .env | cut -d= -f2)
  [ -z "$VAL" ] && echo "WARNUNG: $var ist leer"
done

# Refresh Token Rotation aktiv?
grep "REFRESH_TOKEN_ROTATION" .env docker-compose.yml 2>/dev/null
# Erwartung: true

Failure Scenario

Mit AUTOCONFIRM=true und DISABLE_SIGNUP=false kann jeder einen Account erstellen und sofort nutzen, ohne die E-Mail-Adresse zu bestätigen. Ein Angreifer kann sich mit beliebigen Adressen registrieren und hat sofort die authenticated Rolle in der Datenbank. Ohne Refresh Token Rotation kann ein gestohlener Refresh Token dauerhaft verwendet werden.

B4 - PostgREST: REST API

Was dieser Service macht

PostgREST generiert automatisch eine REST API aus dem PostgreSQL-Schema. Es ist der Hauptzugangspunkt für Daten. Sicherheit liegt primär in den PostgreSQL-Rollen und RLS-Policies, nicht in PostgREST selbst.

Sicherheitsrelevante Konfiguration

rest:
  image: postgrest/postgrest:v14.1       # Version pinnen
  restart: unless-stopped
  environment:
    PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
    PGRST_DB_SCHEMAS: ${PGRST_DB_SCHEMAS}   # public,storage,graphql_public
    PGRST_DB_ANON_ROLE: anon
    PGRST_JWT_SECRET: ${JWT_SECRET}
    PGRST_DB_USE_LEGACY_GUCS: "false"
    PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET}
    PGRST_APP_SETTINGS_JWT_EXP: ${JWT_EXPIRY}

Wie PostgREST mit den Rollen arbeitet:

Request ohne JWT     → PostgREST nutzt Rolle "anon"
Request mit JWT      → PostgREST wechselt zu Rolle aus JWT (z.B. "authenticated")
Request mit service_role JWT → PostgREST nutzt "service_role" (umgeht RLS)

Der authenticator-User verbindet sich zur DB und wechselt per SET ROLE zur jeweiligen Rolle. Das bedeutet: Die Grants auf den Rollen anon und authenticated sind die eigentliche Sicherheitsschicht.

Prüfbare Bedingung

# PostgREST nutzt authenticator-Rolle (nicht postgres)?
grep "PGRST_DB_URI" docker-compose.yml | grep "authenticator"
# Erwartung: Ja

# Schemas explizit definiert (nicht alle)?
grep "PGRST_DB_SCHEMAS" .env
# Erwartung: public,storage,graphql_public (nicht leer = alle Schemas)

Failure Scenario

Wenn PGRST_DB_URI den postgres Superuser statt authenticator verwendet, hat jeder Request Superuser-Rechte, RLS ist wirkungslos. Wenn PGRST_DB_SCHEMAS leer ist, exponiert PostgREST alle Schemas inklusive interner Supabase-Schemas (auth, _realtime, _analytics).

B5 - Realtime: WebSocket Subscriptions

Was dieser Service macht

Realtime ermöglicht WebSocket-basierte Subscriptions auf Datenbankänderungen. Es nutzt den supabase_admin User und das _realtime Schema.

Sicherheitsrelevante Konfiguration

realtime:
  container_name: realtime-dev.supabase-realtime   # Name ist relevant für Tenant-ID
  image: supabase/realtime:v2.68.0
  restart: unless-stopped
  environment:
    PORT: 4000
    DB_HOST: ${POSTGRES_HOST}
    DB_PORT: ${POSTGRES_PORT}
    DB_USER: supabase_admin
    DB_PASSWORD: ${POSTGRES_PASSWORD}
    DB_NAME: ${POSTGRES_DB}
    DB_AFTER_CONNECT_QUERY: 'SET search_path TO _realtime'
    DB_ENC_KEY: supabaserealtime          # ÄNDERN in Produktion
    API_JWT_SECRET: ${JWT_SECRET}
    SECRET_KEY_BASE: ${SECRET_KEY_BASE}   # min. 64 Zeichen
    SEED_SELF_HOST: "true"
    RUN_JANITOR: "true"

Was schiefgehen kann

DB_ENC_KEY: supabaserealtime ist ein Standardwert. In Produktion muss er geändert werden. SECRET_KEY_BASE muss mindestens 64 Zeichen haben, sonst startet der Service nicht oder ist unsicher. Realtime nutzt supabase_admin, was bedeutet, dass es intern vollen DB-Zugriff hat. Die Sicherheit liegt in der JWT-Validierung: Nur authentifizierte User können Subscriptions öffnen, und RLS bestimmt welche Rows sie sehen.

Prüfbare Bedingung

# DB_ENC_KEY nicht auf Default?
grep "DB_ENC_KEY" docker-compose.yml
# NICHT "supabaserealtime"

# SECRET_KEY_BASE lang genug?
SKB_LEN=$(grep "SECRET_KEY_BASE" .env | cut -d= -f2 | wc -c)
[ "$SKB_LEN" -lt 64 ] && echo "KRITISCH: SECRET_KEY_BASE zu kurz ($SKB_LEN Zeichen)"

# Realtime Health Check
docker compose exec -T realtime-dev.supabase-realtime \
  curl -sSf -H "Authorization: Bearer ${ANON_KEY}" \
  http://localhost:4000/api/tenants/realtime-dev/health

B6 - Storage API und MinIO (S3)

Was dieser Service macht

Storage verwaltet Datei-Uploads und Downloads. Standardmässig speichert es Dateien lokal im Volume. Für Produktion empfiehlt sich ein S3-kompatibles Backend (MinIO self-hosted oder ein Cloud-Act-freier S3-Service).

Lokales Storage (Standard)

storage:
  image: supabase/storage-api:v1.33.0
  restart: unless-stopped
  volumes:
    - ./volumes/storage:/var/lib/storage:z
  environment:
    ANON_KEY: ${ANON_KEY}
    SERVICE_KEY: ${SERVICE_ROLE_KEY}
    POSTGREST_URL: http://rest:3000
    PGRST_JWT_SECRET: ${JWT_SECRET}
    DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/${POSTGRES_DB}
    STORAGE_BACKEND: file
    FILE_STORAGE_BACKEND_PATH: /var/lib/storage
    FILE_SIZE_LIMIT: 52428800              # 50MB, anpassen
    ENABLE_IMAGE_TRANSFORMATION: "true"
    IMGPROXY_URL: http://imgproxy:5001

MinIO für S3-Backend (Produktion)

Wenn ihr MinIO nutzt, kommt ein zusätzlicher Service dazu:

# docker-compose.s3.yml (zusätzlich zur Haupt-Compose)
minio:
  image: minio/minio:latest              # Version pinnen in Produktion
  restart: unless-stopped
  ports:
    - "127.0.0.1:9000:9000"              # API, NUR localhost
    - "127.0.0.1:9001:9001"              # Console, NUR localhost
  volumes:
    - minio-data:/data
  environment:
    MINIO_ROOT_USER: ${MINIO_ROOT_USER}
    MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}   # min. 8 Zeichen
  command: server /data --console-address ":9001"

Dann in der Storage-Konfiguration:

storage:
  environment:
    STORAGE_BACKEND: s3
    GLOBAL_S3_BUCKET: supabase-storage
    GLOBAL_S3_ENDPOINT: http://minio:9000
    GLOBAL_S3_FORCE_PATH_STYLE: "true"
    AWS_ACCESS_KEY_ID: ${MINIO_ACCESS_KEY}
    AWS_SECRET_ACCESS_KEY: ${MINIO_SECRET_KEY}
    REGION: eu-central-1

Prüfbare Bedingung

# Storage Health Check
docker compose exec -T storage wget --no-verbose --tries=1 --spider http://localhost:5000/status

# MinIO nicht von aussen erreichbar (wenn genutzt)?
ss -tlnp | grep 9000
# Erwartung: 127.0.0.1:9000 (nicht 0.0.0.0)

# MinIO Default-Credentials?
grep "MINIO_ROOT" .env | grep -iE "minioadmin\|minio123\|admin"
# Erwartung: keine Treffer

# Storage Volume existiert und hat Daten?
ls -la volumes/storage/ 2>/dev/null | head -5

# Storage Bucket Policies (über MinIO Client)
# mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
# mc admin policy ls local

Failure Scenario

MinIO mit Default-Credentials (minioadmin:minioadmin) auf 0.0.0.0:9000 bedeutet: Jeder im Internet kann alle Dateien lesen und schreiben. Die MinIO Console auf Port 9001 gibt zusätzlich eine Web-UI für volle Verwaltung. Storage Bucket Policies können “public” gesetzt sein, was bedeutet, dass Dateien ohne Auth zugänglich sind.

B7 - Analytics, Vector und Supavisor (interne Services)

Was diese Services machen

Diese Services sind nicht direkt von aussen erreichbar, aber sie sind sicherheitsrelevant weil sie privilegierten Datenbankzugriff haben.

Analytics (Logflare): Sammelt und speichert Logs aller Services. Nutzt supabase_admin und das _analytics Schema.

Vector: Log-Pipeline die Docker-Logs an Logflare weiterleitet. Hat Zugriff auf den Docker Socket.

Supavisor: Connection Pooler für PostgreSQL. Verwaltet den Verbindungspool und hat supabase_admin Zugriff.

Sicherheitsrelevante Konfiguration

analytics:
  image: supabase/logflare:1.27.0
  ports:
    - "127.0.0.1:4000:4000"             # NUR localhost
  environment:
    DB_USERNAME: supabase_admin
    DB_PASSWORD: ${POSTGRES_PASSWORD}
    LOGFLARE_PUBLIC_ACCESS_TOKEN: ${LOGFLARE_PUBLIC_ACCESS_TOKEN}
    LOGFLARE_PRIVATE_ACCESS_TOKEN: ${LOGFLARE_PRIVATE_ACCESS_TOKEN}

vector:
  image: timberio/vector:0.28.1-alpine
  volumes:
    - ${DOCKER_SOCKET_LOCATION}:/var/run/docker.sock:ro,z   # Read-Only!
  security_opt:
    - "label=disable"

supavisor:
  image: supabase/supavisor:2.7.4
  ports:
    - "10.0.1.10:5432:5432"             # Pooler exponiert Postgres-Port
    - "127.0.0.1:6543:6543"             # Transaction Mode
  environment:
    DATABASE_URL: ecto://supabase_admin:${POSTGRES_PASSWORD}@${POSTGRES_HOST}:${POSTGRES_PORT}/_supabase
    SECRET_KEY_BASE: ${SECRET_KEY_BASE}
    VAULT_ENC_KEY: ${VAULT_ENC_KEY}
    POOLER_DEFAULT_POOL_SIZE: ${POOLER_DEFAULT_POOL_SIZE}
    POOLER_MAX_CLIENT_CONN: ${POOLER_MAX_CLIENT_CONN}

Was schiefgehen kann

Vector mit Docker Socket Zugriff (/var/run/docker.sock) kann Container-Logs lesen. Der Socket sollte Read-Only gemountet sein (:ro). Analytics auf Port 4000 mit Default-Tokens exponiert Logs aller Services. Supavisor auf 0.0.0.0:5432 statt dem internen Interface macht den Connection Pooler (und damit PostgreSQL) von aussen erreichbar.

Prüfbare Bedingung

# Analytics nur intern?
ss -tlnp | grep 4000
# Erwartung: 127.0.0.1:4000

# Vector Docker Socket Read-Only?
grep "docker.sock" docker-compose.yml | grep ":ro"
# Erwartung: :ro vorhanden

# Supavisor Port nur intern?
ss -tlnp | grep 5432
# Erwartung: 10.0.1.10:5432 (internes Interface)

# Logflare Tokens nicht auf Default?
grep "LOGFLARE.*TOKEN" .env | grep -iE "your-\|change\|example"
# Erwartung: keine Treffer

B8 - Studio: Dashboard absichern

Was dieser Service macht

Studio ist die Web-UI für Datenbankmanagement, User-Verwaltung und SQL-Abfragen. Es hat über den SERVICE_ROLE_KEY und direkten Postgres-Zugang vollen Zugriff auf alles.

Sicherheitsrelevante Konfiguration

Studio wird über Kong mit Basic Auth geschützt (DASHBOARD_USERNAME/DASHBOARD_PASSWORD). Zusätzlich sollte Studio in Produktion entweder gar nicht laufen oder nur über SSH Tunnel erreichbar sein.

Option 1: Studio nicht in Produktion starten (empfohlen)

# In docker-compose.override.yml oder Studio aus dem Compose entfernen
# Studio nur lokal mit supabase start für Entwicklung nutzen

Option 2: Studio nur über SSH Tunnel

# Von der Workstation aus:
ssh -L 3000:localhost:3000 deploy@supabase-prod

# Dann im Browser: http://localhost:3000

Prüfbare Bedingung

# Studio Container läuft? (Sollte in Produktion nicht laufen)
docker compose ps | grep studio
# Empfehlung: nicht running in Produktion

# Wenn Studio läuft: nicht von aussen erreichbar?
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 \
  "https://app.example.com:3000" 2>/dev/null
# Erwartung: Timeout oder Connection Refused

# Dashboard-Passwort stark?
DASH_LEN=$(grep "DASHBOARD_PASSWORD" .env | cut -d= -f2 | wc -c)
[ "$DASH_LEN" -lt 16 ] && echo "WARNUNG: Dashboard-Passwort zu kurz"

Failure Scenario

Studio mit schwachem Dashboard-Passwort und öffentlichem Zugang gibt einem Angreifer eine vollständige Datenbank-Admin-UI. Er kann SQL-Queries ausführen, User löschen, RLS deaktivieren und Daten exportieren. Das ist der Worst Case für einen Self-Hosted Stack.

Service-Checkliste

Nach dem Setup aller Services vor dem ersten produktiven Einsatz prüfen:

Secrets
  [ ] Alle Secrets generiert (keine Default-Werte)
  [ ] JWT_SECRET mindestens 32 Zeichen
  [ ] POSTGRES_PASSWORD mindestens 24 Zeichen
  [ ] DASHBOARD_PASSWORD mindestens 16 Zeichen
  [ ] SECRET_KEY_BASE mindestens 64 Zeichen
  [ ] MINIO_ROOT_PASSWORD mindestens 8 Zeichen (falls MinIO)
  [ ] DB_ENC_KEY geändert (nicht "supabaserealtime")

PostgreSQL
  [ ] Port nur auf internem Interface (10.0.1.10:5432)
  [ ] Init-Scripts unverändert (roles.sql, jwt.sql etc.)
  [ ] Rollen korrekt erstellt (anon nicht superuser)

Kong
  [ ] Port nur auf localhost (127.0.0.1:8000)
  [ ] JWT-Validation auf allen API-Routes aktiv
  [ ] Dashboard-Passwort stark

GoTrue (Auth)
  [ ] JWT_EXP maximal 3600
  [ ] AUTOCONFIRM false
  [ ] SMTP konfiguriert und getestet
  [ ] SITE_URL und API_EXTERNAL_URL korrekt
  [ ] Refresh Token Rotation aktiv
  [ ] DISABLE_SIGNUP gesetzt wenn geschlossene App

PostgREST
  [ ] Nutzt authenticator-Rolle (nicht postgres)
  [ ] DB_SCHEMAS explizit definiert

Realtime
  [ ] DB_ENC_KEY geändert
  [ ] SECRET_KEY_BASE mindestens 64 Zeichen

Storage / MinIO
  [ ] MinIO nicht von aussen erreichbar
  [ ] MinIO Default-Credentials geändert
  [ ] Storage Volume Permissions korrekt

Interne Services
  [ ] Analytics Port nur localhost
  [ ] Vector Docker Socket Read-Only
  [ ] Supavisor Port nur intern
  [ ] Logflare Tokens nicht auf Default

Studio
  [ ] In Produktion deaktiviert ODER nur über SSH Tunnel
  [ ] Dashboard-Passwort stark

Teil C - Supabase-Stack konfigurieren

Diese Schritte betreffen die eigentliche Supabase-Installation und ihre sicherheitsrelevanten Einstellungen.

C1 - Supabase Deployment versionieren

Umsetzung

Alle Infrastrukturdateien gehören in ein Git-Repository. Deployments erfolgen nur über dieses Repository, nie durch manuelle Änderungen auf dem Server.

infra/
  docker-compose.yml
  .env.example            (Template, keine echten Secrets)
  caddy/
    Caddyfile
  postgres/
    migrations/
  scripts/
    backup.sh
    restore.sh
    health-check.sh
    security-check.sh
  runbooks/
    supabase-production.md
    security-baseline.md

Deployment-Workflow

# Auf dem Server
cd /opt/supabase
git pull origin main

# Env Variablen laden (Datei liegt nur auf dem Server)
source .env

# Stack starten/updaten
docker compose up -d

# Health Check
./scripts/health-check.sh

Prüfbare Bedingung

# Gibt es uncommitted Changes auf dem Server?
cd /opt/supabase && git status --porcelain

# Erwartung: leer (keine lokalen Änderungen)

# Ist der Server auf dem aktuellen Stand?
git log --oneline -1
# Vergleich mit Remote
git fetch origin && git diff HEAD origin/main --stat

# Erwartung: keine Differenz

Failure Scenario

Manuelle Änderungen an der docker-compose.yml auf dem Server werden beim nächsten git pull überschrieben oder erzeugen Merge-Konflikte. Schlimmer: Niemand weiß, welche Änderung wann von wem gemacht wurde. Nach einem Server-Verlust ist die Konfiguration nicht reproduzierbar.

C2 - docker-compose.yml: minimaler Produktions-Stack

Umsetzung

Supabase liefert eine Referenz-Compose-Datei mit über 400 Zeilen und ca. 15 Services. Nicht alle werden für Produktion benötigt. Hier die sicherheitsrelevanten Entscheidungen:

Minimaler Stack (diese Services braucht man):

postgres          Datenbank
kong              API Gateway
gotrue            Auth
postgrest         REST API
realtime          WebSocket (falls benötigt)
storage           File Storage (falls benötigt)
meta              Metadaten für PostgREST

Nicht in Produktion (weglassen oder nur intern):

studio            Admin UI, nur über SSH Tunnel oder VPN
imgproxy          nur wenn Bild-Transformationen benötigt
inbucket          nur für lokale E-Mail-Tests

Auszug der sicherheitsrelevanten Konfiguration:

# docker-compose.yml (Auszug, sicherheitsrelevante Teile)

services:
  postgres:
    image: supabase/postgres:15.6.1.143    # Version pinnen
    restart: unless-stopped
    ports:
      - "10.0.1.10:5432:5432"              # NUR internes Interface
    environment:
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    volumes:
      - postgres-data:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U postgres"]
      interval: 30s
      timeout: 10s
      retries: 3

  kong:
    image: kong:2.8.1                       # Version pinnen
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:8000"              # NUR localhost (Reverse Proxy davor)
    environment:
      KONG_DNS_ORDER: LAST,A,CNAME
      KONG_PLUGINS: request-transformer,cors,key-auth,acl
      KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k
      KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k

  gotrue:
    image: supabase/gotrue:v2.164.0         # Version pinnen
    restart: unless-stopped
    environment:
      GOTRUE_JWT_SECRET: ${JWT_SECRET}
      GOTRUE_JWT_EXP: 3600                  # 1 Stunde, nicht mehr
      GOTRUE_EXTERNAL_EMAIL_ENABLED: true
      GOTRUE_MAILER_AUTOCONFIRM: false      # E-Mail Bestätigung erzwingen
      GOTRUE_DISABLE_SIGNUP: false           # auf true setzen wenn Registrierung geschlossen
      GOTRUE_RATE_LIMIT_HEADER: X-Real-IP
      GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: true
      GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10
      API_EXTERNAL_URL: https://app.example.com

  postgrest:
    image: postgrest/postgrest:v12.2.3      # Version pinnen
    restart: unless-stopped
    environment:
      PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@postgres:5432/postgres
      PGRST_DB_ANON_ROLE: anon
      PGRST_JWT_SECRET: ${JWT_SECRET}

volumes:
  postgres-data:

networks:
  default:
    driver: bridge

Kritische Konfigurationspunkte:

GOTRUE_JWT_EXP: 3600         nicht höher als 3600 (1h)
GOTRUE_MAILER_AUTOCONFIRM    false in Produktion
GOTRUE_DISABLE_SIGNUP         true wenn keine offene Registrierung
REFRESH_TOKEN_ROTATION        true (verhindert Token Reuse)
Image Versionen               immer pinnen, nie :latest
Postgres Port                 nur auf internem Interface binden
Kong Port                     nur auf localhost (Reverse Proxy davor)

Prüfbare Bedingung

# Images gepinnt (kein :latest)?
grep "image:" docker-compose.yml | grep -c "latest"
# Erwartung: 0

# Postgres nur intern erreichbar?
docker compose exec postgres ss -tlnp | grep 5432
# Erwartung: nur 10.0.1.10:5432 oder 0.0.0.0:5432 (dann Firewall prüfen)

# Von außen Postgres nicht erreichbar?
nmap -p 5432 app.example.com
# Erwartung: filtered oder closed

# JWT Expiry korrekt?
grep "GOTRUE_JWT_EXP" .env
# Erwartung: 3600 oder weniger

# Supabase Studio nicht von außen erreichbar?
curl -s -o /dev/null -w "%{http_code}" https://app.example.com:3000
# Erwartung: Timeout oder Connection Refused

Failure Scenario

Ungepinnte Images (image: supabase/gotrue:latest) können bei einem docker compose pull unbemerkt eine neue Version einführen, die Breaking Changes enthält oder eine bekannte Vulnerability hat. Wenn Postgres auf 0.0.0.0:5432 lauscht und die Firewall temporär ausfällt, ist die gesamte Datenbank im Internet erreichbar. Wenn GOTRUE_JWT_EXP auf 86400 (24h) steht, ist ein gestohlenes Token einen ganzen Tag lang gültig.

C3 - Secrets vollständig und sicher verwalten

Umsetzung

Ein Supabase-Stack hat mindestens diese Secrets:

# .env (nur auf dem Server, nie im Git)

# Kern-Secrets
JWT_SECRET=                    # min. 32 Zeichen, generiert mit openssl rand -base64 32
ANON_KEY=                      # JWT Token mit anon-Rolle
SERVICE_ROLE_KEY=              # JWT Token mit service_role, umgeht RLS
POSTGRES_PASSWORD=             # min. 24 Zeichen, generiert

# Dashboard
DASHBOARD_USERNAME=            # Supabase Studio Login
DASHBOARD_PASSWORD=            # min. 16 Zeichen

# E-Mail (GoTrue)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
SMTP_SENDER_NAME=

# Storage (falls S3-Backend)
S3_ACCESS_KEY=
S3_SECRET_KEY=

Secrets generieren:

# JWT Secret
openssl rand -base64 32

# Postgres Passwort
openssl rand -base64 24

# Anon und Service Role Keys generieren (Supabase CLI)
# Oder manuell JWT erstellen mit dem JWT_SECRET

Secret-Management auf dem Server:

# .env Datei mit restriktiven Rechten
chmod 600 /opt/supabase/.env
chown deploy:deploy /opt/supabase/.env

# Prüfen dass .env nicht im Git liegt
cat /opt/supabase/.gitignore | grep ".env"

Im Repository liegt nur das Template:

# .env.example (im Git)
JWT_SECRET=generate-with-openssl-rand-base64-32
ANON_KEY=generate-jwt-with-anon-role
SERVICE_ROLE_KEY=generate-jwt-with-service-role
POSTGRES_PASSWORD=generate-with-openssl-rand-base64-24
DASHBOARD_USERNAME=admin
DASHBOARD_PASSWORD=generate-min-16-chars
SMTP_HOST=
SMTP_PORT=587
SMTP_USER=
SMTP_PASS=
SMTP_SENDER_NAME=

Das gleiche Prinzip gilt für jede Datensicherheit in der Enterprise-KI-Infrastruktur.

Prüfbare Bedingung

# .env nicht im Git?
cd /opt/supabase && git ls-files .env
# Erwartung: leer

# .env in .gitignore?
grep "^\.env$" .gitignore
# Erwartung: .env

# Dateirechte korrekt?
stat -c "%a %U" .env
# Erwartung: 600 deploy

# Secrets haben ausreichende Länge?
awk -F= '{if (length($2) < 16 && $2 != "" && $1 !~ /PORT|HOST|NAME/) print "ZU KURZ: "$1}' .env
# Erwartung: keine Ausgabe

# Keine Default-Passwörter?
grep -iE "password|secret" .env | grep -iE "change.me|default|example|your.*here"
# Erwartung: keine Treffer

Failure Scenario

Das häufigste Sicherheitsproblem bei Supabase Self-Hosting ist nicht ein Server-Exploit, sondern ein geleakter Secret. Wenn .env ins Git committed wird und das Repository öffentlich ist (oder wird), liegen alle Secrets offen. Mit dem SERVICE_ROLE_KEY lässt sich die gesamte Datenbank ohne RLS lesen und schreiben.

C4 - Datenbank-Policies prüfen

Umsetzung

Bei Supabase liegt ein großer Teil der Sicherheit in PostgreSQL Row Level Security (RLS), nicht im Applikationsserver. Jede Tabelle im public Schema muss RLS aktiviert haben.

Tabellen ohne RLS finden:

-- Alle public-Tabellen ohne RLS
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
  AND rowsecurity = false;

Tabellen mit RLS aber ohne Policies finden:

-- RLS aktiv aber keine Policy definiert = kein Zugriff möglich
-- (kann gewollt sein, aber prüfen)
SELECT t.tablename
FROM pg_tables t
LEFT JOIN pg_policies p ON t.tablename = p.tablename
WHERE t.schemaname = 'public'
  AND t.rowsecurity = true
  AND p.policyname IS NULL;

Zu offene Policies finden:

-- Policies die allen Rollen vollen Zugriff geben
SELECT tablename, policyname, permissive, roles, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
  AND (roles = '{public}' OR qual = 'true');

Service Role Nutzung prüfen:

-- Welche Rollen existieren und welche Rechte haben sie?
SELECT rolname, rolsuper, rolcreaterole, rolcreatedb
FROM pg_roles
WHERE rolname IN ('anon', 'authenticated', 'service_role', 'authenticator');

Prüfbare Bedingung

# Als automatisiertes Script vom audit-runner
ssh supabase-prod "docker compose exec -T postgres psql -U postgres -c \"
  SELECT tablename FROM pg_tables
  WHERE schemaname = 'public' AND rowsecurity = false;
\""

# Erwartung: keine Tabellen (oder nur bewusst ausgenommene)

Failure Scenario

Eine Tabelle users mit rowsecurity = false ist über die PostgREST API für jeden mit dem anon Key vollständig lesbar. Das betrifft alle Spalten, auch E-Mail-Adressen, Telefonnummern und andere personenbezogene Daten. Ein einfacher curl Befehl mit dem öffentlichen anon Key reicht.

Teil D - Betrieb und Überwachung

Diese Schritte laufen regelmäßig und automatisiert.

D1 - Backups automatisieren

Umsetzung

Tägliche PostgreSQL-Dumps, verschlüsselt und extern gespeichert.

#!/bin/bash
# scripts/backup.sh

set -euo pipefail

BACKUP_DIR="/opt/backups"
DATE=$(date +%Y-%m-%d_%H%M)
RETENTION_DAYS=30
GPG_RECIPIENT="backup@example.com"   # GPG Key ID

# PostgreSQL Dump
docker compose exec -T postgres pg_dump \
  -U postgres \
  --format=custom \
  --compress=9 \
  postgres > "${BACKUP_DIR}/db_${DATE}.dump"

# Storage Buckets sichern (falls Supabase Storage genutzt)
docker compose exec -T storage tar -czf - /var/lib/storage \
  > "${BACKUP_DIR}/storage_${DATE}.tar.gz"

# Verschlüsseln
for file in "${BACKUP_DIR}"/*_${DATE}.*; do
  gpg --encrypt --recipient "${GPG_RECIPIENT}" "$file"
  rm "$file"   # Unverschlüsselte Version löschen
done

# Auf externen Server kopieren (audit-runner oder S3)
rsync -az "${BACKUP_DIR}/"*_${DATE}*.gpg \
  deploy@10.0.1.11:/opt/backup-archive/

# Alte Backups löschen (lokal)
find "${BACKUP_DIR}" -name "*.gpg" -mtime +${RETENTION_DAYS} -delete

# Auf dem Backup-Server ebenfalls aufräumen
ssh deploy@10.0.1.11 \
  "find /opt/backup-archive -name '*.gpg' -mtime +${RETENTION_DAYS} -delete"

echo "Backup ${DATE} abgeschlossen"
# Cron Job einrichten
# crontab -e
0 3 * * * /opt/supabase/scripts/backup.sh >> /var/log/backup.log 2>&1

Prüfbare Bedingung

# Backup von heute vorhanden?
ls -la /opt/backups/*_$(date +%Y-%m-%d)*.gpg

# Backup auf externem Server angekommen?
ssh deploy@10.0.1.11 "ls -la /opt/backup-archive/*_$(date +%Y-%m-%d)*.gpg"

# Backup-Größe plausibel (nicht 0 Bytes)?
find /opt/backups -name "*.gpg" -size 0 -print
# Erwartung: keine Treffer

Failure Scenario

Backups nur auf demselben Server zu speichern bedeutet: wenn der Server ausfällt oder verschlüsselt wird (Ransomware), sind auch die Backups weg. Unverschlüsselte Backups auf einem externen Server sind ein Datenleck, da der Dump alle Tabellendaten im Klartext enthält.

D2 - Restore regelmäßig testen

Umsetzung

Einmal pro Monat ein Backup auf einem Testsystem wiederherstellen.

#!/bin/bash
# scripts/restore-test.sh

set -euo pipefail

BACKUP_FILE=$1   # z.B. /opt/backup-archive/db_2026-03-01_0300.dump.gpg

# Entschlüsseln
gpg --decrypt "$BACKUP_FILE" > /tmp/restore-test.dump

# Test-Container starten
docker run -d --name restore-test \
  -e POSTGRES_PASSWORD=testpassword \
  supabase/postgres:15.6.1.143

sleep 10

# Restore
docker exec -i restore-test pg_restore \
  -U postgres \
  --dbname=postgres \
  --clean \
  --if-exists \
  < /tmp/restore-test.dump

# Tabellen prüfen
docker exec restore-test psql -U postgres -c \
  "SELECT schemaname, tablename FROM pg_tables WHERE schemaname = 'public';"

# Row Counts prüfen
docker exec restore-test psql -U postgres -c \
  "SELECT relname, n_live_tup FROM pg_stat_user_tables ORDER BY n_live_tup DESC LIMIT 10;"

# Aufräumen
docker rm -f restore-test
rm /tmp/restore-test.dump

echo "Restore-Test abgeschlossen"

Prüfbare Bedingung

# Restore-Test Script ausführen und Exit Code prüfen
./scripts/restore-test.sh /opt/backup-archive/db_latest.dump.gpg
echo $?
# Erwartung: 0

# Log des letzten Restore-Tests prüfen
cat /var/log/restore-test.log | tail -20

Failure Scenario

Viele Teams haben Backups die seit Monaten laufen, aber noch nie einen Restore getestet haben. Typische Probleme: falsches pg_dump Format (Plain Text statt Custom), fehlende Berechtigungen beim Restore, inkompatible PostgreSQL-Versionen zwischen Backup und Restore. All das fällt erst auf, wenn man den Restore wirklich braucht.

D3 - Tägliche Infrastruktur-Checks

Umsetzung

Ein Script auf dem audit-runner prüft täglich den Zustand des Produktionssystems.

#!/bin/bash
# scripts/security-check.sh (läuft auf audit-runner)

set -euo pipefail

PROD_HOST="10.0.1.10"
REPORT=""
CRITICAL=0

# 1. Container Status
STOPPED=$(ssh deploy@${PROD_HOST} "docker compose ps --format json" | \
  jq -r 'select(.State != "running") | .Name')
if [ -n "$STOPPED" ]; then
  REPORT+="KRITISCH: Container nicht running: ${STOPPED}\n"
  CRITICAL=1
fi

# 2. Offene Ports von außen
OPEN_PORTS=$(nmap -p 22,80,443,3000,5432,8000,9000 app.example.com \
  --open -oG - | grep "/open/" | grep -v "443/open")
if [ -n "$OPEN_PORTS" ]; then
  REPORT+="KRITISCH: Unerwartete offene Ports: ${OPEN_PORTS}\n"
  CRITICAL=1
fi

# 3. Zertifikat Ablaufdatum
CERT_EXPIRY=$(echo | openssl s_client -connect app.example.com:443 2>/dev/null | \
  openssl x509 -noout -enddate | cut -d= -f2)
CERT_EPOCH=$(date -d "$CERT_EXPIRY" +%s)
NOW_EPOCH=$(date +%s)
DAYS_LEFT=$(( (CERT_EPOCH - NOW_EPOCH) / 86400 ))
if [ "$DAYS_LEFT" -lt 14 ]; then
  REPORT+="WARNUNG: TLS Zertifikat läuft in ${DAYS_LEFT} Tagen ab\n"
fi

# 4. Firewall Drift
FIREWALL_DIFF=$(ssh deploy@${PROD_HOST} "iptables-save" | \
  diff /opt/baselines/firewall-baseline.txt - || true)
if [ -n "$FIREWALL_DIFF" ]; then
  REPORT+="WARNUNG: Firewall hat sich geändert:\n${FIREWALL_DIFF}\n"
fi

# 5. Docker Image Versionen (Drift gegen Baseline)
IMAGE_DIFF=$(ssh deploy@${PROD_HOST} \
  "docker compose images --format '{{.Repository}}:{{.Tag}}'" | \
  diff /opt/baselines/image-versions.txt - || true)
if [ -n "$IMAGE_DIFF" ]; then
  REPORT+="WARNUNG: Container-Versionen haben sich geändert:\n${IMAGE_DIFF}\n"
fi

# 6. Disk Space
DISK_USAGE=$(ssh deploy@${PROD_HOST} "df -h / | tail -1 | awk '{print \$5}' | tr -d '%'")
if [ "$DISK_USAGE" -gt 85 ]; then
  REPORT+="WARNUNG: Disk Usage bei ${DISK_USAGE}%\n"
fi

# 7. Backup Status
LAST_BACKUP=$(ssh deploy@${PROD_HOST} "ls -t /opt/backups/*.gpg 2>/dev/null | head -1")
if [ -z "$LAST_BACKUP" ]; then
  REPORT+="KRITISCH: Kein Backup gefunden\n"
  CRITICAL=1
else
  BACKUP_AGE=$(ssh deploy@${PROD_HOST} \
    "echo \$(( (\$(date +%s) - \$(stat -c %Y ${LAST_BACKUP})) / 3600 ))")
  if [ "$BACKUP_AGE" -gt 26 ]; then
    REPORT+="WARNUNG: Letztes Backup ist ${BACKUP_AGE} Stunden alt\n"
  fi
fi

# 8. RLS Check
UNPROTECTED=$(ssh deploy@${PROD_HOST} "docker compose exec -T postgres psql -U postgres -t -c \
  \"SELECT count(*) FROM pg_tables WHERE schemaname = 'public' AND rowsecurity = false;\"" | tr -d ' ')
if [ "$UNPROTECTED" -gt 0 ]; then
  REPORT+="WARNUNG: ${UNPROTECTED} Tabellen ohne RLS\n"
fi

# Ergebnis
echo "=== Security Check $(date) ==="
if [ -n "$REPORT" ]; then
  echo -e "$REPORT"
else
  echo "Alle Checks bestanden"
fi

# Bei kritischen Findings: Alert senden
if [ "$CRITICAL" -eq 1 ]; then
  echo -e "$REPORT" | mail -s "KRITISCH: Security Check $(date)" ops@example.com
fi
# Cron auf dem audit-runner
0 7 * * * /opt/audit/scripts/security-check.sh >> /var/log/security-check.log 2>&1

Prüfbare Bedingung

# Check-Script lief heute?
grep "$(date +%Y-%m-%d)" /var/log/security-check.log | tail -1
# Erwartung: Eintrag von heute vorhanden

# Ergebnis?
grep "Alle Checks bestanden\|KRITISCH\|WARNUNG" /var/log/security-check.log | tail -5

D4 - Claude Code als kontextuelle Analyse-Schicht

Claude Code analysiert die Ergebnisse der deterministischen Checks und erkennt Zusammenhänge, die Scripts nicht sehen. Das Drei-Ebenen-Modell aus dem Claude Code Runbook beschreibt diesen Ansatz im Detail.

Architektur

Tägliche Checks (D3)
     |
     +-- Deterministische Findings
     |   (offene Ports, Firewall Drift, fehlende Backups)
     |
     +---> Wöchentlicher Claude Code Review
          |
          +-- Config Dateien (docker-compose.yml, Caddyfile, .env.example)
          +-- Security Check Logs der letzten 7 Tage
          +-- Git Diff der Infrastruktur-Änderungen
          +-- RLS Policy Export
          |
          +---> Priorisierter Bericht
               |
               +---> DevOps Entscheidung (Mensch)

Konkretes Script

#!/bin/bash
# scripts/claude-review-prep.sh (läuft auf audit-runner)

REPORT_DIR="/opt/audit/weekly-reports"
DATE=$(date +%Y-%m-%d)
OUTPUT="${REPORT_DIR}/review-input-${DATE}.md"

mkdir -p "$REPORT_DIR"

cat > "$OUTPUT" << 'HEADER'
# Wöchentlicher Security Review Input

## Kontext
Self-hosted Supabase Stack auf Hetzner Cloud.
Architektur: Reverse Proxy -> Kong -> Supabase Services -> PostgreSQL
Audit-System auf separatem Server.
HEADER

# Security Check Logs der letzten 7 Tage
echo -e "\n## Security Check Ergebnisse (letzte 7 Tage)\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
grep -A 20 "Security Check" /var/log/security-check.log | \
  tail -100 >> "$OUTPUT"
echo '```' >> "$OUTPUT"

# Aktuelle Docker Compose Config (ohne Secrets)
echo -e "\n## Aktuelle docker-compose.yml\n" >> "$OUTPUT"
echo '```yaml' >> "$OUTPUT"
ssh deploy@10.0.1.10 "cat /opt/supabase/docker-compose.yml" >> "$OUTPUT"
echo '```' >> "$OUTPUT"

# Git Diff der letzten Woche
echo -e "\n## Infrastruktur-Änderungen (letzte 7 Tage)\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
ssh deploy@10.0.1.10 "cd /opt/supabase && git log --oneline --since='7 days ago'" >> "$OUTPUT"
ssh deploy@10.0.1.10 "cd /opt/supabase && git diff HEAD~5 --stat" >> "$OUTPUT" 2>/dev/null
echo '```' >> "$OUTPUT"

# RLS Status
echo -e "\n## RLS Status\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
ssh deploy@10.0.1.10 "docker compose exec -T postgres psql -U postgres -c \"
  SELECT tablename, rowsecurity FROM pg_tables WHERE schemaname = 'public' ORDER BY tablename;
\"" >> "$OUTPUT"
echo '```' >> "$OUTPUT"

# Container Versionen
echo -e "\n## Container Versionen\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
ssh deploy@10.0.1.10 "docker compose images --format '{{.Repository}}:{{.Tag}}'" >> "$OUTPUT"
echo '```' >> "$OUTPUT"

echo "Review-Input erstellt: $OUTPUT"

Was Claude Code nicht tut

Claude führt KEINE automatischen Änderungen auf Production aus.
Claude deployed NICHT.
Claude rotiert KEINE Secrets.
Claude hat KEINEN direkten Zugriff auf den Produktionsserver.

Claude analysiert Daten die ihm übergeben werden
und erstellt Berichte für menschliche Entscheidungen.

Deployment-Checkliste

Vor der ersten Inbetriebnahme und nach größeren Änderungen:

Teil A - Infrastruktur
  [ ] Zwei separate Server (prod + audit)
  [ ] Privates Netzwerk konfiguriert und getestet
  [ ] Cloud Firewall aktiv (nur 443, SSH von Admin-IP)
  [ ] Host Firewall aktiv (iptables)
  [ ] Firewall-Baseline gespeichert
  [ ] SSH: nur Keys, kein Root, kein Passwort
  [ ] Reverse Proxy konfiguriert (Caddy/Nginx)
  [ ] TLS aktiv mit automatischem Renewal
  [ ] Security Headers gesetzt (HSTS, CSP, X-Frame-Options)
  [ ] WebSocket Proxy für Realtime konfiguriert

Teil B - Supabase Services
  Secrets (B0)
    [ ] Alle Secrets generiert (keine Default-Werte)
    [ ] JWT_SECRET mindestens 32 Zeichen
    [ ] POSTGRES_PASSWORD mindestens 24 Zeichen
    [ ] DASHBOARD_PASSWORD mindestens 16 Zeichen
    [ ] SECRET_KEY_BASE mindestens 64 Zeichen
    [ ] MINIO_ROOT_PASSWORD mindestens 8 Zeichen (falls MinIO)
    [ ] DB_ENC_KEY geändert (nicht "supabaserealtime")
  PostgreSQL (B1)
    [ ] Port nur auf internem Interface (10.0.1.10:5432)
    [ ] Init-Scripts unverändert (roles.sql, jwt.sql etc.)
    [ ] Rollen korrekt erstellt (anon nicht superuser)
  Kong (B2)
    [ ] Port nur auf localhost (127.0.0.1:8000)
    [ ] JWT-Validation auf allen API-Routes aktiv
    [ ] Dashboard-Passwort stark
  GoTrue (B3)
    [ ] JWT_EXP maximal 3600
    [ ] AUTOCONFIRM false
    [ ] SMTP konfiguriert und getestet
    [ ] SITE_URL und API_EXTERNAL_URL korrekt
    [ ] Refresh Token Rotation aktiv
    [ ] DISABLE_SIGNUP gesetzt wenn geschlossene App
  PostgREST (B4)
    [ ] Nutzt authenticator-Rolle (nicht postgres)
    [ ] DB_SCHEMAS explizit definiert
  Realtime (B5)
    [ ] DB_ENC_KEY geändert
    [ ] SECRET_KEY_BASE mindestens 64 Zeichen
  Storage / MinIO (B6)
    [ ] MinIO nicht von aussen erreichbar
    [ ] MinIO Default-Credentials geändert
    [ ] Storage Volume Permissions korrekt
  Interne Services (B7)
    [ ] Analytics Port nur localhost
    [ ] Vector Docker Socket Read-Only
    [ ] Supavisor Port nur intern
    [ ] Logflare Tokens nicht auf Default
  Studio (B8)
    [ ] In Produktion deaktiviert ODER nur über SSH Tunnel
    [ ] Dashboard-Passwort stark

Teil C - Stack-Konfiguration
  [ ] docker-compose.yml versioniert im Git
  [ ] Alle Image Versionen gepinnt (kein :latest)
  [ ] .env nicht im Git
  [ ] .env Dateirechte 600
  [ ] .env.example im Git als Template
  [ ] Keine Default-Passwörter
  [ ] RLS auf allen public-Tabellen aktiv
  [ ] Keine Tabellen ohne Policies (außer bewusst)
  [ ] Keine zu offenen Policies (qual = 'true')

Teil D - Betrieb und Überwachung
  [ ] Täglicher Backup-Job aktiv
  [ ] Backups verschlüsselt
  [ ] Backups extern gespeichert (audit-runner oder S3)
  [ ] Restore-Test mindestens einmal durchgeführt
  [ ] Retention-Strategie konfiguriert
  [ ] Täglicher Security-Check-Job auf audit-runner
  [ ] Alerting bei kritischen Findings
  [ ] Wöchentlicher Claude Code Review eingerichtet

Teil E - Updates und Maintenance
  [ ] Unattended Upgrades installiert und aktiv
  [ ] Auto-Update-Intervall auf täglich konfiguriert
  [ ] Supabase Images gepinnt (kein :latest)
  [ ] Kein Supabase Image älter als 90 Tage
  [ ] Update-Commit innerhalb der letzten 45 Tage
  [ ] Auto-Patch Script für PostgreSQL Minor Patches aktiv
  [ ] Auto-Patch Cron NACH Backup-Cron (03:00 nach 02:00)
  [ ] Security Release Monitor auf audit-runner aktiv (täglich)
  [ ] Trivy installiert auf audit-runner
  [ ] Maintenance Check Cron auf audit-runner (wöchentlich Montag)
  [ ] TLS Zertifikat mindestens 14 Tage gültig
  [ ] Disk Usage unter 85%

Teil E - Updates und Maintenance

Ein self-hosted Stack der nicht regelmässig aktualisiert wird, sammelt Sicherheitslücken an. Ungepatchte CVEs in PostgreSQL, Kong oder GoTrue sind reale Angriffsvektoren. Gleichzeitig können Updates Breaking Changes einführen die den Stack lahmlegen.

Deshalb braucht der Update-Prozess klare Regeln: Was wird wann aktualisiert, wie wird getestet, und wie wird sichergestellt dass nichts vergessen wird.

E1 - Drei Update-Ebenen

Der Stack hat drei unabhängige Update-Ebenen mit unterschiedlichen Rhythmen und Risiken.

Ebene 1: OS-Level (Ubuntu)
   │  Was: Kernel, System-Packages, OpenSSL, Docker Engine
   │  Rhythmus: Wöchentlich Security Patches, monatlich Full Update
   │  Risiko: Gering (apt upgrade ist stabil)
   │  Methode: apt update && apt upgrade

Ebene 2: Supabase Services (Docker Images)
   │  Was: PostgreSQL, Kong, GoTrue, PostgREST, Realtime, Storage, etc.
   │  Rhythmus: Monatlich (Supabase Release Cycle)
   │  Risiko: Mittel bis hoch (Breaking Changes zwischen Versionen)
   │  Methode: Image Tags in docker-compose.yml ändern, pull, restart

Ebene 3: Reverse Proxy und Tools
   │  Was: Caddy, iptables, GPG, nmap, jq
   │  Rhythmus: Bei Security Advisories oder quartalsweise
   │  Risiko: Gering
   │  Methode: apt upgrade (Caddy über eigenes Repo)

Prüfbare Bedingung:

# OS: Wann war das letzte apt upgrade?
stat -c %y /var/cache/apt/pkgcache.bin
# Erwartung: weniger als 7 Tage alt

# Supabase: Welche Image-Versionen laufen?
cd /opt/supabase && docker compose images --format '{{.Repository}}:{{.Tag}}'

# Caddy Version
caddy version

Failure Scenario: Ein ungepatchter PostgreSQL mit einer bekannten Remote Code Execution Vulnerability (wie CVE-2023-5869) kann von einem Angreifer ausgenutzt werden, selbst wenn RLS korrekt konfiguriert ist. Ein veralteter Kong mit einer bekannten Auth-Bypass-Vulnerability kann JWT-Validation umgehen.

E2 - OS-Level Updates

Wöchentlich: Security Patches (automatisch)

# Unattended Upgrades installieren und konfigurieren
sudo apt install -y unattended-upgrades

# Konfiguration: nur Security Updates automatisch
sudo tee /etc/apt/apt.conf.d/50unattended-upgrades > /dev/null << 'EOF'
Unattended-Upgrade::Allowed-Origins {
    "${distro_id}:${distro_codename}-security";
};

// Automatischer Reboot wenn nötig (z.B. Kernel Update)
// Nur aktivieren wenn ihr damit leben könnt dass der Server
// um 4:00 Uhr nachts kurz neustartet
Unattended-Upgrade::Automatic-Reboot "false";

// E-Mail Benachrichtigung bei Updates
Unattended-Upgrade::Mail "ops@example.com";
Unattended-Upgrade::MailReport "on-change";
EOF

# Automatische Updates aktivieren
sudo tee /etc/apt/apt.conf.d/20auto-upgrades > /dev/null << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
EOF

sudo systemctl enable unattended-upgrades
sudo systemctl start unattended-upgrades

Monatlich: Full System Update (manuell, mit Prüfung)

# Zuerst prüfen was aktualisiert wird
apt list --upgradable

# Dann updaten
sudo apt update && sudo apt upgrade -y

# Docker Engine Update (separat, über Docker Repo)
sudo apt install --only-upgrade docker-ce docker-ce-cli containerd.io

# Nach Kernel-Updates: Reboot nötig?
if [ -f /var/run/reboot-required ]; then
  echo "REBOOT ERFORDERLICH"
fi

Prüfbare Bedingung:

# Unattended Upgrades aktiv?
systemctl is-active unattended-upgrades

# Letzte automatische Updates
cat /var/log/unattended-upgrades/unattended-upgrades.log | tail -20

# Ausstehende Security Updates?
apt list --upgradable 2>/dev/null | grep -i security | wc -l
# Erwartung: 0

E3 - Supabase Service Updates

Supabase veröffentlicht ca. monatlich neue Docker Images. Der Update-Prozess muss kontrolliert ablaufen weil Breaking Changes zwischen Versionen möglich sind.

Workflow:

1. Release Notes lesen (github.com/supabase/supabase/releases)
2. Neue Image Tags in docker-compose.yml eintragen
3. Auf Staging/Test testen (oder: Backup + Rollback-Plan)
4. Backup erstellen
5. docker compose pull
6. docker compose down && docker compose up -d
7. Health Checks prüfen
8. Baselines aktualisieren

Aktuelle Versionen prüfen vs. verfügbar:

cd /opt/supabase

echo "=== Aktuelle Versionen ==="
docker compose images --format '{{.Repository}}:{{.Tag}}'

echo ""
echo "=== Verfügbare Updates ==="
# Die offiziellen Releases prüfen
echo "Supabase Releases: https://github.com/supabase/supabase/releases"
echo "GoTrue: https://github.com/supabase/gotrue/releases"
echo "PostgREST: https://github.com/PostgREST/postgrest/releases"
echo "Realtime: https://github.com/supabase/realtime/releases"
echo ""
echo "Oder den offiziellen docker-compose.yml vergleichen:"
echo "https://raw.githubusercontent.com/supabase/supabase/master/docker/docker-compose.yml"

Sicherer Update-Ablauf:

cd /opt/supabase

# 1. Backup VOR dem Update
./scripts/backup.sh

# 2. Aktuelle Versionen dokumentieren
docker compose images --format '{{.Repository}}:{{.Tag}}' > /opt/baselines/pre-update-versions.txt

# 3. Neue Versionen in docker-compose.yml eintragen
# MANUELL: Image Tags auf die neue Version ändern
# z.B. supabase/gotrue:v2.164.0 → supabase/gotrue:v2.184.0

# 4. Pull neue Images
docker compose pull

# 5. Stack neu starten
docker compose down
docker compose up -d

# 6. Warten und Health Checks
sleep 30
docker compose ps --format "table {{.Name}}\t{{.Status}}"

# 7. Alle Services prüfen
docker compose exec -T auth wget --no-verbose --tries=1 --spider http://localhost:9999/health
docker compose exec -T db pg_isready -U postgres -h localhost
curl -s -o /dev/null -w "Kong: %{http_code}\n" http://localhost:8000
curl -s -o /dev/null -w "Storage: %{http_code}\n" http://localhost:8000/storage/v1/

# 8. Baselines aktualisieren
docker compose images --format '{{.Repository}}:{{.Tag}}' > /opt/baselines/image-versions.txt

# 9. Git Commit
git add docker-compose.yml
git commit -m "Update Supabase images to [VERSION]"

Rollback wenn etwas schiefgeht:

cd /opt/supabase

# Alte Versionen wiederherstellen
git checkout HEAD~1 -- docker-compose.yml
docker compose pull
docker compose down
docker compose up -d

# Wenn Datenbank-Migration das Problem ist:
./scripts/restore.sh /opt/backups/[LETZTES_BACKUP].gpg

Prüfbare Bedingung:

# Image-Versionen älter als 3 Monate?
cd /opt/supabase
docker compose images --format '{{.Repository}}:{{.Tag}}' | while read image; do
  CREATED=$(docker inspect --format='{{.Created}}' "$image" 2>/dev/null | cut -dT -f1)
  if [ -n "$CREATED" ]; then
    AGE_DAYS=$(( ($(date +%s) - $(date -d "$CREATED" +%s)) / 86400 ))
    [ "$AGE_DAYS" -gt 90 ] && echo "WARNUNG: $image ist $AGE_DAYS Tage alt"
  fi
done

Failure Scenario: Supabase GoTrue v2.170.0 hatte eine Änderung im Refresh Token Handling die ältere Clients brach. Ohne vorheriges Lesen der Release Notes und ohne Backup wäre das ein Ausfall gewesen. PostgreSQL Major Version Updates (z.B. 15 -> 16) erfordern einen pg_dump/pg_restore Zyklus, ein einfacher Image-Tag-Wechsel reicht nicht.

E3b - Automatisches Patching (Ebene 1)

Nicht alle Updates erfordern einen Menschen. PostgreSQL Minor Patches und Caddy Updates sind risikoarm und können nachts automatisch eingespielt werden.

Zwei-Ebenen-Modell:

EBENE 1 - AUTOMATISCH (auto-patch.sh, täglich 03:00):
  OS Security Patches (unattended-upgrades)
  PostgreSQL Minor Patches (15.8.1.x -> 15.8.1.y)
  Caddy Updates
  -> Info-Mail nach erfolgreichem Patch
  -> Alarm-Mail bei Health Check Fehler
  -> Automatischer Rollback bei Fehler

EBENE 2 - MANUELL (/supabase-update, innerhalb 24h nach Alert):
  GoTrue/Auth (Breaking Changes möglich)
  PostgREST (Query-Verhalten kann sich ändern)
  Kong (Routing kann sich ändern)
  Realtime, Storage, Supavisor
  PostgreSQL MAJOR (15 -> 16, braucht pg_dump/pg_restore)

Das Script auto-patch.sh läuft täglich um 03:00 (nach dem Backup um 02:00) und:

  1. Prüft ob ein aktuelles Backup existiert (Abbruch wenn nicht)
  2. Sichert den aktuellen Zustand (docker-compose.yml, Image Versionen)
  3. Prüft ob ein neues PostgreSQL Minor Image verfügbar ist
  4. Spielt es ein und macht einen Health Check
  5. Bei Fehler: automatischer Rollback auf die vorherige Version
  6. Sendet eine Info-Mail (Erfolg) oder Alarm-Mail (Fehler)

Prüfbare Bedingung:

# Auto-Patch Cron aktiv?
crontab -l | grep auto-patch
# Erwartung: 0 3 * * * /opt/supabase/scripts/auto-patch.sh

# Letzter Auto-Patch Lauf?
tail -20 /var/log/auto-patch.log

# Hat Auto-Patch jemals gepatcht?
grep "Patches eingespielt\|Keine Patches" /var/log/auto-patch.log | tail -5

Cron-Zeitplan (prod-Server):

02:00 täglich  -> Backup (DB + Storage + extern)
03:00 täglich  -> Auto-Patch (PostgreSQL Minor + Caddy)
04:00 monatl.  -> Restore-Test

E4 - Update-Zeitplan und Verantwortlichkeiten

Wöchentlich (automatisch):
  [ ] OS Security Patches (unattended-upgrades)
  [ ] Audit-Runner prüft ob Patches angewendet wurden

Monatlich (manuell, geplant):
  [ ] Supabase Release Notes prüfen
  [ ] Neue Image Tags evaluieren
  [ ] Backup erstellen
  [ ] Update durchführen
  [ ] Health Checks
  [ ] Baselines aktualisieren

Quartalsweise (Review):
  [ ] Caddy Version prüfen
  [ ] Docker Engine Version prüfen
  [ ] Node.js Version prüfen (für Claude Code auf audit-runner)
  [ ] PostgreSQL Major Version evaluieren
  [ ] Gesamte Toolchain auf aktuellem Stand?

Bei Security Advisories (sofort):
  [ ] CVE betrifft unseren Stack?
  [ ] Betroffenes Image/Package identifizieren
  [ ] Patch verfügbar?
  [ ] Notfall-Update durchführen

Prüfbare Bedingung:

# Wann war das letzte Supabase Update?
cd /opt/supabase
git log --oneline --grep="Update\|update\|upgrade" | head -5

# Wie alt ist der letzte Update-Commit?
LAST_UPDATE=$(git log --format="%ai" --grep="Update\|update" -1 2>/dev/null | cut -d' ' -f1)
if [ -n "$LAST_UPDATE" ]; then
  AGE=$(( ($(date +%s) - $(date -d "$LAST_UPDATE" +%s)) / 86400 ))
  echo "Letztes Update: $LAST_UPDATE ($AGE Tage her)"
  [ "$AGE" -gt 45 ] && echo "WARNUNG: Letztes Update über 45 Tage her"
fi

E5 - Security Release Monitor (täglich)

Der grösste Blindspot bei Self-Hosting ist nicht die initiale Konfiguration, sondern das Verpassen von Security Patches. Wenn Supabase GoTrue einen Auth-Bypass-Fix veröffentlicht, muss das Team innerhalb von 24 Stunden handeln, nicht nach einer Woche.

Auf dem audit-runner läuft täglich ein Script das die GitHub Releases aller Supabase-Komponenten prüft und bei Security Releases sofort per E-Mail alarmiert.

Geprüfte Komponenten:

supabase/auth (GoTrue)          -> häufige Security Patches
PostgREST/postgrest             -> API-Layer
supabase/realtime               -> WebSocket
supabase/storage-api            -> File Storage
Kong/kong                       -> API Gateway
supabase/edge-runtime           -> Edge Functions
supabase/postgres               -> Datenbank Image
supabase/supavisor              -> Connection Pooler
moby/moby (Docker Engine)       -> Container Runtime

Drei Prüfschichten:

Schicht 1: GitHub Releases
  -> Gibt es eine neue Version?
  -> Enthalten die Release Notes "security"/"CVE"?

Schicht 2: Trivy Container Scan
  -> Scannt jedes laufende Docker Image gegen NVD/GitHub Advisories
  -> Findet CVEs in allen Abhängigkeiten (OS-Packages, Libraries)

Schicht 3: OSV API
  -> Prüft Application-Level Vulnerabilities für GoTrue, PostgREST etc.
  -> Ergänzt Trivy um Package-spezifische CVEs

Wie es funktioniert:

Täglich 07:00 (audit-runner Cron)

   ├── Aktuelle Versionen vom prod-Server holen
   ├── GitHub API: Neueste Releases für jede Komponente prüfen
   ├── Release Notes auf "security", "CVE", "vulnerability" scannen

   ├── Security Release gefunden?
   │     -> SOFORT E-Mail an ops@
   │     -> "Handlung innerhalb 24h erforderlich"

   └── Normales Release gefunden?
         -> Wöchentliche Zusammenfassung (Montag)

Cache-Mechanismus: Das Script merkt sich gemeldete Releases, sodass nicht jeden Tag dieselbe E-Mail kommt. Erst bei einem NEUEN Release wird wieder alarmiert.

Prüfbare Bedingung:

# Security Release Monitor aktiv auf audit-runner?
ssh deploy@10.0.1.11 "crontab -l | grep security-release"
# Erwartung: täglicher Cron Job

# Letzter Check-Log
ssh deploy@10.0.1.11 "tail -5 /var/log/security-releases.log"
# Erwartung: Eintrag von heute

# Cache vorhanden (Script hat schon gelaufen)?
ssh deploy@10.0.1.11 "ls /opt/audit/cache/*-last-seen.txt 2>/dev/null | wc -l"

Failure Scenario: Im Januar 2024 wurde CVE-2023-5869 für PostgreSQL veröffentlicht (Remote Code Execution). Wer keinen Release Monitor hatte und nur monatlich Updates prüfte, war 3-4 Wochen lang verwundbar. Mit dem täglichen Monitor wäre die E-Mail am Tag nach dem Release eingegangen.

E6 - Audit-Runner als Update-Wächter

Der Audit-Runner überwacht ob Updates durchgeführt werden und informiert das Team wenn etwas überfällig ist.

Auf dem audit-runner läuft ein wöchentliches Script das folgendes prüft:

#!/bin/bash
# scripts/check-maintenance.sh (läuft auf audit-runner)

PROD_HOST="10.0.1.10"
REPORT=""

echo "=== Maintenance Check $(date) ==="

# 1. OS Updates überfällig?
LAST_APT=$(ssh deploy@${PROD_HOST} "stat -c %Y /var/cache/apt/pkgcache.bin")
APT_AGE=$(( ($(date +%s) - $LAST_APT) / 86400 ))
if [ "$APT_AGE" -gt 7 ]; then
  REPORT+="WARNUNG: apt update ist ${APT_AGE} Tage her (max. 7)\n"
fi

# 2. Unattended Upgrades aktiv?
UA_STATUS=$(ssh deploy@${PROD_HOST} "systemctl is-active unattended-upgrades 2>/dev/null")
if [ "$UA_STATUS" != "active" ]; then
  REPORT+="KRITISCH: Unattended Upgrades nicht aktiv\n"
fi

# 3. Ausstehende Security Updates?
SEC_UPDATES=$(ssh deploy@${PROD_HOST} "apt list --upgradable 2>/dev/null | grep -ci security")
if [ "$SEC_UPDATES" -gt 0 ]; then
  REPORT+="WARNUNG: ${SEC_UPDATES} ausstehende Security Updates\n"
fi

# 4. Reboot erforderlich?
REBOOT=$(ssh deploy@${PROD_HOST} "test -f /var/run/reboot-required && echo ja || echo nein")
if [ "$REBOOT" = "ja" ]; then
  REPORT+="WARNUNG: Server-Reboot erforderlich (Kernel Update)\n"
fi

# 5. Supabase Image Alter
ssh deploy@${PROD_HOST} "cd /opt/supabase && docker compose images --format '{{.Repository}}:{{.Tag}}'" | \
while read image; do
  CREATED=$(ssh deploy@${PROD_HOST} "docker inspect --format='{{.Created}}' '$image' 2>/dev/null" | cut -dT -f1)
  if [ -n "$CREATED" ]; then
    AGE_DAYS=$(( ($(date +%s) - $(date -d "$CREATED" +%s 2>/dev/null || echo 0)) / 86400 ))
    if [ "$AGE_DAYS" -gt 90 ]; then
      REPORT+="WARNUNG: $image ist ${AGE_DAYS} Tage alt (max. 90)\n"
    fi
  fi
done

# 6. Letztes Supabase Update?
LAST_UPDATE=$(ssh deploy@${PROD_HOST} "cd /opt/supabase && git log --format='%ai' --grep='Update\|update' -1 2>/dev/null" | cut -d' ' -f1)
if [ -n "$LAST_UPDATE" ]; then
  UPDATE_AGE=$(( ($(date +%s) - $(date -d "$LAST_UPDATE" +%s)) / 86400 ))
  if [ "$UPDATE_AGE" -gt 45 ]; then
    REPORT+="WARNUNG: Letztes Supabase Update vor ${UPDATE_AGE} Tagen (max. 45)\n"
  fi
else
  REPORT+="WARNUNG: Kein Update-Commit in Git gefunden\n"
fi

# 7. Docker Engine Version
DOCKER_VERSION=$(ssh deploy@${PROD_HOST} "docker version --format '{{.Server.Version}}' 2>/dev/null")
echo "Docker Engine: $DOCKER_VERSION"

# 8. Caddy Version
CADDY_VERSION=$(ssh deploy@${PROD_HOST} "caddy version 2>/dev/null")
echo "Caddy: $CADDY_VERSION"

# 9. TLS Zertifikat Restlaufzeit
CERT_DAYS=$(ssh deploy@${PROD_HOST} "echo | openssl s_client -connect localhost:443 2>/dev/null | openssl x509 -noout -enddate 2>/dev/null | cut -d= -f2")
if [ -n "$CERT_DAYS" ]; then
  DAYS_LEFT=$(( ($(date -d "$CERT_DAYS" +%s) - $(date +%s)) / 86400 ))
  [ "$DAYS_LEFT" -lt 14 ] && REPORT+="KRITISCH: TLS Zertifikat läuft in ${DAYS_LEFT} Tagen ab\n"
fi

# Ergebnis
if [ -n "$REPORT" ]; then
  echo -e "\n$REPORT"
  echo -e "$REPORT" | mail -s "Maintenance Check: Handlung nötig" ops@example.com
else
  echo "Alle Maintenance Checks bestanden"
fi

Cron auf dem audit-runner:

# Wöchentlich Montag 08:00 (nach dem Sonntag-Audit)
0 8 * * 1 /opt/audit/scripts/check-maintenance.sh >> /var/log/maintenance-check.log 2>&1

Wann Claude informiert:

Claude Code auf dem audit-runner sendet Benachrichtigungen in drei Fällen:

SOFORT (E-Mail an ops@):
  - Unattended Upgrades nicht aktiv
  - TLS Zertifikat < 14 Tage
  - Security Update ausstehend und > 3 Tage alt

WÖCHENTLICH (Maintenance Report):
  - Reboot erforderlich
  - apt update überfällig
  - Supabase Images > 60 Tage alt

MONATLICH (Update Reminder):
  - Supabase Release Notes nicht geprüft (kein Update-Commit > 45 Tage)
  - Quartalsreview fällig

Fazit

Supabase selbst zu hosten ist relativ einfach. Supabase sicher zu betreiben erfordert klare Architekturregeln und automatisierte Kontrolle.

Dieses Runbook trennt zwischen Infrastruktur-Entscheidungen (Teil A), Service-Architektur und -Absicherung (Teil B), Stack-Konfiguration (Teil C), laufender Überwachung (Teil D) und Update-Prozessen (Teil E). Die Kombination aus deterministischen Checks und kontextueller Claude Code Analyse deckt sowohl bekannte Muster als auch unerwartete Risiken ab.

Wer diese Prinzipien von Anfang an verfolgt, baut eine Cert-Ready-by-Design-Architektur und spart sich nachträgliche Audit-Runden.

Zur Erinnerung: Die Hetzner-spezifischen Konfigurationen (vSwitch, Cloud Firewall, Interface-Namen) lassen sich direkt auf andere EU-Hoster übertragen: OVH (FR), Netcup (DE), IONOS (DE), Scaleway (FR). Für Brasilien: Locaweb oder Magalu Cloud (BR). Die Architekturprinzipien gelten hosterunabhängig.

Audit-Checkliste als Download

Vorbereiteter Prompt für Claude Code. Laden Sie die Datei auf Ihren Server und starten Sie Claude Code im Projektverzeichnis Ihres Supabase-Stacks. Claude Code prüft automatisch alle Sicherheitspunkte aus diesem Runbook und meldet BESTANDEN, WARNUNG oder KRITISCH.

claude -p "$(cat claude-check-artikel-1-supabase.md)" --allowedTools Read,Grep,Glob,Bash

Checkliste herunterladen

Serien-Inhaltsverzeichnis

Dieser Artikel ist Teil unserer DevOps-Serie für self-hosted App-Stacks.

  1. Supabase Self-Hosting Runbook - dieser Artikel
  2. Next.js über Supabase sicher betreiben
  3. Supabase Edge Functions sicher einsetzen
  4. Trigger.dev Background Jobs sicher betreiben
  5. Claude Code als Sicherheitskontrolle im DevOps-Workflow
  6. Security Baseline für den gesamten Stack

Im nächsten Artikel zeigen wir, wie Next.js sicher über Supabase betrieben wird, ohne typische Fehler bei Server Actions, Auth-Handling und API-Zugriffen zu machen.

Bert Gogolin

Bert Gogolin

Geschäftsführer, Gosign

AI Governance Briefing

Enterprise AI, Regulierung und Infrastruktur - einmal im Monat, direkt von mir.

Kein Spam. Jederzeit abbestellbar. Datenschutzerklärung

Supabase Self-Hosting DevOps Security PostgreSQL
Artikel teilen

Häufige Fragen

Welche Komponenten hat der Supabase Self-Hosting-Stack?

Der Supabase-Stack besteht aus PostgreSQL, PostgREST API, GoTrue Auth, Realtime Server, Storage, Kong API Gateway und Supabase Studio. Optional kommen Edge Functions hinzu. Damit betreibt man eine komplette Backend-Plattform, nicht nur eine Datenbank.

Warum braucht man zwei Server für Supabase Self-Hosting?

Die Trennung von Produktionssystem und Audit-System verhindert, dass ein kompromittierter Server gleichzeitig seine eigenen Sicherheitschecks manipulieren kann. Das Audit-System bleibt unabhängig und kann Konfigurationsdrift zuverlässig erkennen.

Ist dieser Artikel Teil einer Serie?

Ja. Dieses Runbook ist Teil 1 einer sechsteiligen DevOps-Serie für self-hosted App-Stacks. Die Serie umfasst Supabase, Next.js, Edge Functions, Trigger.dev, Claude Code als Sicherheitskontrolle und eine Security Baseline.