Przejdź do treści
Infrastruktura & Technologia

Supabase Self-Hosting Runbook: bezpieczna architektura

Supabase Self-Hosting Runbook: konfiguracja Hetzner, Docker Compose, architektura usług, sekrety, RLS i backupy.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 30 min czytania

Samodzielne hostowanie Supabase jest technicznie stosunkowo proste. Bezpieczna i stabilna eksploatacja Supabase jest znacznie bardziej wymagająca.

Powód: Supabase to nie pojedyncza baza danych, lecz kompletna platforma backendowa. Self-hosted stack Supabase składa się zwykle z kilku komponentów: PostgreSQL, PostgREST API, GoTrue Auth, Realtime Server, Storage, Kong API Gateway, Supabase Studio i opcjonalnie Edge Functions.

Statystyka: Zgodnie z raportem Supabase z 2025 roku ponad 68% instancji self-hosted nie ma aktywnego RLS na wszystkich tabelach publicznych - co czyni tę kontrolę najczęściej pomijaną.

Oznacza to, że faktycznie eksploatujemy platformę backendową, a nie tylko bazę danych. Podobne rozważania dotyczą również self-hostingu modeli językowych.

Ten runbook opisuje minimalne setup produkcyjne, które można bezpiecznie eksploatować i które jednocześnie pozostaje zautomatyzowane. Każdy krok zawiera konkretną implementację, sprawdzalny warunek i scenariusz awarii.

Uwaga dotycząca hostingu: Ten runbook wykorzystuje Hetzner Cloud jako przykład infrastruktury, ponieważ Hetzner jest szeroko rozpowszechniony w regionie DACH, oferuje niemieckie centra danych i ma dobry stosunek ceny do wydajności. Zasady architektoniczne obowiązują jednak niezależnie od hostingu. Elementy specyficzne dla Hetzner (vSwitch, Cloud Firewall API, Robot Panel) można bezpośrednio przenieść na innych dostawców: OVH vRack (PL - datacenter Warszawa), Oktawave (PL), IONOS Cloud Network (EU). Tam, gdzie krok jest specyficzny dla Hetzner, wyraźnie to zaznaczamy.

W skrócie - Artykuł 1 z 6 serii DevOps Runbook

  • Architektura dwuserwerowa rozdziela produkcję i audyt
  • Docker Compose z przypiętymi wersjami obrazów
  • Siedem usług (PostgreSQL, PostgREST, GoTrue, Kong, Realtime, Storage, Studio)
  • RLS na wszystkich publicznych tabelach
  • Codzienne szyfrowane backupy przechowywane zewnętrznie

Spis treści serii

Ten przewodnik jest częścią naszej serii runbooków DevOps dla nowoczesnych self-hosted stacków aplikacyjnych.

  1. Supabase Self-Hosting Runbook ← ten artykuł
  2. Next.js nad Supabase - bezpieczna eksploatacja
  3. Supabase Edge Functions - bezpieczne wdrożenie
  4. Bezpieczna obsługa Trigger.dev Background Jobs
  5. Claude Code jako kontrola bezpieczeństwa w DevOps
  6. Security Baseline dla całego stacku

Artykuł 1 obejmuje infrastrukturę, usługi i konfigurację stacku. Kolejne artykuły budują na niej.

Przegląd usług

UsługaPortFunkcjaKrytyczna konfiguracja bezpieczeństwa
PostgreSQL5432Przechowywanie danychRLS na wszystkich tabelach, listen_address tylko wewnętrzny
PostgREST3000REST APIAutomatyczne wymuszanie RLS, role-based access
GoTrue9999UwierzytelnianieJWT expiry, email confirmation, refresh token rotation
Kong8000API GatewayRate limiting, routing, JWT validation
Realtime4000WebSocketAutoryzacja kanałów, limity połączeń
Storage5000Object StorageBucket policies, limity rozmiaru plików
Studio3000Panel administracyjnyDostęp tylko wewnętrzny (SSH Tunnel/VPN)

Architektura docelowa

Stabilne setup rozdziela co najmniej dwa obszary odpowiedzialności.

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

Równolegle działa drugi, oddzielny system:

Audit Server
   |
   +-- Security Checks    (Lynis, Trivy, Port Scans)
   +-- Drift Detection     (Config Diffs wobec Baseline)
   +-- Claude Code Review  (kontekstowa analiza)
   +-- Monitoring          (metryki, alerty)
   +-- Backup Verification (testy przywracania)

Dlaczego to rozdzielenie jest konieczne: jeśli system produkcyjny i audytowy są identyczne, skompromitowany serwer może jednocześnie manipulować własnymi kontrolami bezpieczeństwa.

Część A - Decyzje infrastrukturalne

Te decyzje podejmuje się raz i stanowią one fundament dla wszystkiego dalszego.

A1 - Rozdzielenie infrastruktury: dwa serwery

Realizacja

Eksploatacja co najmniej dwóch serwerów, fizycznych lub jako oddzielne maszyny wirtualne:

supabase-prod     (Hetzner Cloud CX32 lub wyższy)
audit-runner      (Hetzner Cloud CX22 wystarczy)

Specyficzne dla Hetzner: W konsoli Hetzner Cloud w sekcji “Servers” utworzyć dwie oddzielne instancje, obie w tym samym projekcie i tej samej lokalizacji (np. fsn1). U innych dostawców: dwie maszyny wirtualne w tym samym regionie/strefie.

supabase-prod obsługuje cały stack Supabase i PostgreSQL. audit-runner obsługuje kontrole bezpieczeństwa, monitoring, Drift Detection i analizę Claude Code.

Sprawdzalny warunek

# Oba serwery muszą być oddzielnymi hostami
ssh supabase-prod hostname
ssh audit-runner hostname

# Oczekiwanie: różne nazwy hostów i adresy IP

Scenariusz awarii

Jeśli audyt i produkcja działają na tym samym serwerze i atakujący uzyska dostęp root, może usuwać logi, manipulować wynikami kontroli bezpieczeństwa i blokować alerty. Kompromitacja pozostaje niewykryta.

A2 - Konfiguracja sieci prywatnej

Realizacja

Oba serwery komunikują się wewnętrznie przez sieć prywatną. Usługi Supabase są dostępne wyłącznie przez te wewnętrzne adresy IP.

Specyficzne dla Hetzner: W sekcji “Networks” utworzyć vSwitch lub Cloud Network z podsiecią 10.0.1.0/24. Oba serwery przypisać do sieci. Hetzner automatycznie tworzy interfejs (typowo ens10). W OVH (PL): vRack. W Oktawave (PL): Prywatna sieć. W IONOS (EU): Cloud Network.

# Na obu serwerach skonfigurować prywatny interfejs
# /etc/network/interfaces.d/60-private.cfg (specyficzne dla Hetzner)

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

# Sprawdzenie
ip addr show ens10
ping 10.0.1.11   # z serwera prod

Sprawdzalny warunek

# Z audit-runner: wewnętrzny interfejs aktywny?
ssh audit-runner "ip addr show ens10 | grep 10.0.1.11"

# PostgreSQL może nasłuchiwać tylko wewnętrznie
ssh supabase-prod "ss -tlnp | grep 5432"

# Oczekiwanie: 10.0.1.10:5432, NIE 0.0.0.0:5432

Scenariusz awarii

Jeśli PostgreSQL nasłuchuje na 0.0.0.0:5432 i firewall ma błąd, baza danych jest bezpośrednio dostępna z internetu. Z kluczem service_role lub słabym hasłem Postgres cała baza danych jest skompromitowana.

A3 - Reverse Proxy z TLS

Realizacja

Przed Kongiem stoi reverse proxy, który terminuje TLS i jest jedyną usługą dostępną z zewnątrz. Caddy jest tu użyty jako przykład, ponieważ ma wbudowane automatyczne Let’s Encrypt.

# 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
    }
}

Alternatywnie z 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;

        # Obsługa WebSocket dla Realtime
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }
}

Sprawdzalny warunek

# TLS aktywny i poprawnie skonfigurowany?
curl -I https://app.example.com

# Oczekiwanie: HTTP/2 200, nagłówek Strict-Transport-Security obecny

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

# Data wygaśnięcia certyfikatu
echo | openssl s_client -connect app.example.com:443 2>/dev/null | \
  openssl x509 -noout -enddate

# Oczekiwanie: notAfter co najmniej 14 dni w przyszłości

# Nagłówki bezpieczeństwa obecne?
curl -sI https://app.example.com | grep -E \
  "X-Frame-Options|Strict-Transport-Security|X-Content-Type-Options"

Scenariusz awarii

Bez TLS tokeny uwierzytelniania przesyłane są przez sieć w postaci jawnej. Każdy w tym samym segmencie sieci może je przechwycić (Man-in-the-Middle). Bez automatycznego odnawiania certyfikatów certyfikat wygasa po 90 dniach i aplikacja staje się niedostępna.

A4 - Podwójny firewall

Realizacja

Dwie warstwy, które wzajemnie się zabezpieczają:

Warstwa 1: Cloud Firewall (przed serwerem)

Specyficzne dla Hetzner: W sekcji “Firewalls” utworzyć nowy firewall i przypisać go do obu serwerów. W OVH (PL): Security Groups. W Oktawave (PL): Firewall. W IONOS (EU): Cloud Firewall.

# Reguły Hetzner Cloud Firewall dla supabase-prod

Inbound:
  TCP 443    z 0.0.0.0/0        (HTTPS)
  TCP 22     z ADMIN_IP/32       (SSH tylko z IP administratora)
  TCP ALL    z 10.0.1.0/24       (sieć wewnętrzna)

Outbound:
  ALL        do 0.0.0.0/0        (aktualizacje, DNS, Let's Encrypt)

Warstwa 2: Host Firewall (na serwerze)

# /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 tylko z IP administratora
-A INPUT -p tcp --dport 22 -s ADMIN_IP -j ACCEPT

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

# Sieć wewnętrzna (wszystkie porty)
-A INPUT -s 10.0.1.0/24 -j ACCEPT

# Wszystko inne odrzucane (Default Policy)
COMMIT
# Aktywacja firewalla
apt install iptables-persistent
iptables-restore < /etc/iptables/rules.v4

# Zapisanie baseliny do Drift Detection
iptables-save > /root/firewall-baseline.txt

Sprawdzalny warunek

# Z zewnątrz: tylko 443 otwarty
nmap -p 22,80,443,5432,8000,9000 app.example.com

# Oczekiwanie: tylko 443 open (22 tylko z IP administratora)

# Na serwerze: reguły aktywne?
iptables -L -n | grep -c "DROP"

# Oczekiwanie: co najmniej 1 (Default DROP Policy)

# Drift Detection: czy firewall się zmienił?
iptables-save | diff /root/firewall-baseline.txt -

# Oczekiwanie: brak odchyleń

Scenariusz awarii

Pojedynczy firewall może być błędnie skonfigurowany. Polecenie iptables -F (flush) na serwerze otwiera wszystkie porty, jeśli nie ma Cloud Firewalla. Z drugiej strony Cloud Firewall nie chroni przed procesami, które na samym serwerze otwierają nowe porty, jeśli nie jest aktywny Host Firewall.

A5 - Zabezpieczenie dostępu SSH

Realizacja

# /etc/ssh/sshd_config

PasswordAuthentication no
 PermitRootLogin no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
AllowUsers deploy
# Umieszczenie klucza SSH na serwerze
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

# Przeładowanie SSHD
systemctl reload sshd

Sprawdzalny warunek

# Logowanie hasłem musi się nie powieść
ssh -o PasswordAuthentication=yes -o PubkeyAuthentication=no deploy@app.example.com

# Oczekiwanie: Permission denied

# Logowanie jako root musi się nie powieść
ssh root@app.example.com

# Oczekiwanie: Permission denied

# Sprawdzenie konfiguracji
sshd -T | grep -E "passwordauthentication|permitrootlogin"

# Oczekiwanie: passwordauthentication no, permitrootlogin no

Scenariusz awarii

Dostęp SSH oparty na hasłach jest stale atakowany z internetu (Brute Force). Słabe hasło jest typowo łamane w ciągu godzin. Logowanie jako root oznacza, że atakujący natychmiast uzyskuje pełną kontrolę nad serwerem.

Część B - Konfiguracja i zabezpieczanie usług Supabase

Self-hosted stack Supabase składa się z ponad 10 usług. Każda z nich ma własne zmienne środowiskowe, własne role bazodanowe i własne wymagania bezpieczeństwa. Oficjalny docker-compose.yml Supabase ma ponad 400 linii i zawiera wiele ustawień, które nie wyglądają na istotne z punktu widzenia bezpieczeństwa, ale mimo to takimi są.

Ten rozdział wyjaśnia każdą usługę, jej rolę, konfigurację istotną dla bezpieczeństwa oraz typowe błędy przy konfiguracji.

Przegląd usług

Internet

   │  HTTPS (443)

Reverse Proxy (Caddy/Nginx)     ← Część A3

Kong (API Gateway)               ← routuje do wszystkich usług

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

   ├── Meta (Postgres-Meta)       ← wewnętrzne, dla Studio
   ├── ImgProxy                   ← wewnętrzne, dla Storage
   ├── Analytics (Logflare)       ← wewnętrzne, dla logów
   ├── Vector                     ← wewnętrzne, pipeline logów
   └── Supavisor (Pooler)         ← Connection Pooling

PostgreSQL                        ← baza danych ze skryptami Init

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

B0 - Generowanie sekretów (przed pierwszym uruchomieniem)

To musi nastąpić PRZED uruchomieniem wszystkich usług. Oficjalny .env.example zawiera placeholdery. Muszą one zostać zastąpione wygenerowanymi wartościami.

# JWT Secret (współdzielony przez GoTrue, PostgREST, Realtime, Kong)
JWT_SECRET=$(openssl rand -base64 32)

# Hasło Postgres
POSTGRES_PASSWORD=$(openssl rand -base64 24)

# Hasło dashboardu (Basic Auth przez Kong)
DASHBOARD_PASSWORD=$(openssl rand -base64 16)

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

# Hasło root MinIO (jeśli używany S3 Storage)
MINIO_ROOT_PASSWORD=$(openssl rand -hex 16)

# Realtime Secret Key Base (min. 64 znaki)
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 i Service Role Key (JWT, generowane z JWT_SECRET)
# Użyj https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys
# lub wygeneruj ręcznie za pomocą jwt.io i JWT_SECRET

Reguła bezpieczeństwa: Wszystkie sekrety trafiają do pliku .env na serwerze (uprawnienia 600, nie w Git). Supabase używa jednego JWT_SECRET dla wszystkich usług. Jeśli ten sekret zostanie skompromitowany, GoTrue, PostgREST i Realtime są jednocześnie dotknięte.

Warunek weryfikowalny

# Wszystkie wymagane sekrety ustawione?
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 "KRYTYCZNE: $var nie jest ustawiony lub ma wartość domyślną"
  fi
done

# Brak identycznych haseł?
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 "OSTRZEŻENIE: Niektóre sekrety mają identyczne wartości"
fi

Scenariusz awarii

Domyślne sekrety z .env.example są publicznie znane. Atakujący może z domyślnym JWT_SECRET wygenerować ważne tokeny i uzyskać pełny dostęp do API. Z domyślnym SERVICE_ROLE_KEY dodatkowo omija wszystkie polityki RLS.

B1 - PostgreSQL: baza danych i role

Co robi ta usługa

PostgreSQL to centralna baza danych. W kontekście Supabase pełni szczególną rolę: przechowuje nie tylko dane aplikacyjne, ale także dane uwierzytelniania (GoTrue), konfigurację Realtime, metadane Storage i logi Analytics. Skrypty Init tworzą specjalne schematy i role.

Konfiguracja istotna dla bezpieczeństwa

db:
  image: supabase/postgres:15.8.1.085    # Własny obraz Supabase, wersja przypięta
  restart: unless-stopped
  ports:
    - "10.0.1.10:5432:5432"              # TYLKO wewnętrzny interfejs
  volumes:
    # Skrypty Init (tworzą schematy, role, rozszerzenia)
    - ./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
    # Dane trwałe
    - ./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}

Skrypty Init tworzą następujące role:

postgres              → Superuser (tylko do administracji, nigdy dla usług)
anon                  → Nieuwierzytelnione zapytania (przez PostgREST)
authenticated         → Uwierzytelnione zapytania (przez PostgREST)
service_role          → Omija RLS (do operacji administracyjnych)
authenticator         → PostgREST używa tej roli do łączenia się
supabase_admin        → Wewnętrzna rola administracyjna (Realtime, Analytics)
supabase_auth_admin   → Specyficzna dla GoTrue (schemat auth)
supabase_storage_admin → Specyficzna dla Storage (schemat storage)

Co może pójść nie tak

Plik roles.sql definiuje granty dla każdej roli. Jeśli ten plik zostanie zmodyfikowany (np. aby szybko rozwiązać problem), role mogą uzyskać zbyt wiele uprawnień. Rola anon powinna mieć tylko te uprawnienia, które polityki RLS jawnie zezwalają.

Warunek weryfikowalny

# Sprawdzenie ról i ich uprawnień
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 NIE może być superuserem
docker compose exec -T db psql -U postgres -c \
  "SELECT rolname, rolsuper FROM pg_roles WHERE rolname = 'anon' AND rolsuper = true;"
# Oczekiwanie: brak wierszy

# Postgres nasłuchuje TYLKO wewnętrznie
ss -tlnp | grep 5432
# Oczekiwanie: 10.0.1.10:5432, NIE 0.0.0.0:5432

B2 - Kong: API Gateway i routing

Co robi ta usługa

Kong jest centralnym punktem wejścia dla wszystkich zapytań API. Routuje na podstawie ścieżki URL do odpowiednich usług, obsługuje walidację JWT i chroni dashboard Studio za pomocą Basic Auth.

Konfiguracja istotna dla bezpieczeństwa

kong:
  image: kong:2.8.1                       # Wersja przypięta
  restart: unless-stopped
  ports:
    - "127.0.0.1:8000:8000"              # TYLKO localhost (Reverse Proxy przed nim)
    - "127.0.0.1:8443:8443"              # HTTPS wewnętrznie
  volumes:
    - ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
  environment:
    KONG_DATABASE: "off"                   # Konfiguracja deklaratywna, bez bazy danych
    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'

Plik kong.yml definiuje 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 (opcjonalnie)
/                 → Studio (port 3000)        + Basic Auth

Co może pójść nie tak

Kong na 0.0.0.0:8000 zamiast 127.0.0.1:8000 oznacza, że API jest bezpośrednio dostępne bez Reverse Proxy (bez TLS). Słabe hasło dashboardu daje przez Basic Auth dostęp do Studio, a tym samym do całej bazy danych. Plik kong.yml może być skonfigurowany tak, że walidacja JWT jest wyłączona dla niektórych tras.

Warunek weryfikowalny

# Kong tylko na localhost?
ss -tlnp | grep 8000
# Oczekiwanie: 127.0.0.1:8000

# Hasło dashboardu wystarczająco silne?
DASH_PW_LEN=$(grep "DASHBOARD_PASSWORD" .env | cut -d= -f2 | wc -c)
[ "$DASH_PW_LEN" -lt 16 ] && echo "OSTRZEŻENIE: Hasło dashboardu za krótkie"

# kong.yml: JWT Validation aktywna na wszystkich trasach API?
grep -A5 "key-auth" volumes/api/kong.yml | head -20

B3 - GoTrue: uwierzytelnianie

Co robi ta usługa

GoTrue obsługuje rejestrację użytkowników, logowanie, Magic Links, OAuth, MFA i wydawanie tokenów. Jest jedyną usługą wystawiającą JWT i ma własnego użytkownika DB (supabase_auth_admin) z dostępem do schematu auth.

Konfiguracja istotna dla bezpieczeństwa

auth:
  image: supabase/gotrue:v2.184.0       # Wersja przypięta
  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

    # Baza danych (własny użytkownik administracyjny)
    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}            # MAKSYMALNIE 3600 (1 godzina)
    GOTRUE_JWT_AUD: authenticated
    GOTRUE_JWT_ADMIN_ROLES: service_role
    GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated

    # Rejestracja
    GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP}  # true jeśli zamknięta aplikacja
    GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
    GOTRUE_MAILER_AUTOCONFIRM: false          # ZAWSZE false w produkcji

    # SMTP (dla Magic Links, potwierdzenia e-mail)
    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}

    # Ścieżki URL Mailera (muszą pasować do routingu Kong)
    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 po Auth)
    GOTRUE_SITE_URL: ${SITE_URL}              # URL waszej aplikacji Next.js
    GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}

    # Rotacja Refresh Token (zapobiega ponownemu użyciu tokena)
    GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: true
    GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10

Konfiguracja SMTP: dlaczego jest istotna dla bezpieczeństwa

Bez działającego serwera SMTP nie można wysyłać e-maili potwierdzających. Jeśli ustawiono GOTRUE_MAILER_AUTOCONFIRM: true, aby to obejść, każdy może się zarejestrować z dowolnym adresem e-mail. Oznacza to: brak weryfikacji tożsamości.

Warunek weryfikowalny

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

# JWT Expiry nie wyższy niż 3600?
grep "JWT_EXPIRY\|JWT_EXP" .env | head -1
# Oczekiwanie: 3600 lub mniej

# Autoconfirm wyłączony?
grep "AUTOCONFIRM" .env
# Oczekiwanie: false

# SMTP skonfigurowany (nie pusty)?
for var in SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS; do
  VAL=$(grep "^${var}=" .env | cut -d= -f2)
  [ -z "$VAL" ] && echo "OSTRZEŻENIE: $var jest pusty"
done

# Rotacja Refresh Token aktywna?
grep "REFRESH_TOKEN_ROTATION" .env docker-compose.yml 2>/dev/null
# Oczekiwanie: true

Scenariusz awarii

Z AUTOCONFIRM=true i DISABLE_SIGNUP=false każdy może utworzyć konto i natychmiast z niego korzystać bez potwierdzenia adresu e-mail. Atakujący może się zarejestrować z dowolnymi adresami i natychmiast uzyskuje rolę authenticated w bazie danych. Bez rotacji Refresh Token skradziony Refresh Token może być używany na stałe.

B4 - PostgREST: REST API

Co robi ta usługa

PostgREST automatycznie generuje REST API ze schematu PostgreSQL. Jest głównym punktem dostępu do danych. Bezpieczeństwo leży przede wszystkim w rolach PostgreSQL i politykach RLS, a nie w samym PostgREST.

Konfiguracja istotna dla bezpieczeństwa

rest:
  image: postgrest/postgrest:v14.1       # Wersja przypięta
  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}

Jak PostgREST pracuje z rolami:

Zapytanie bez JWT     → PostgREST używa roli "anon"
Zapytanie z JWT       → PostgREST przełącza się na rolę z JWT (np. "authenticated")
Zapytanie z service_role JWT → PostgREST używa "service_role" (omija RLS)

Użytkownik authenticator łączy się z bazą danych i przełącza się przez SET ROLE na odpowiednią rolę. Oznacza to: granty na rolach anon i authenticated są właściwą warstwą bezpieczeństwa.

Warunek weryfikowalny

# PostgREST używa roli authenticator (nie postgres)?
grep "PGRST_DB_URI" docker-compose.yml | grep "authenticator"
# Oczekiwanie: tak

# Schematy jawnie zdefiniowane (nie wszystkie)?
grep "PGRST_DB_SCHEMAS" .env
# Oczekiwanie: public,storage,graphql_public (nie puste = wszystkie schematy)

Scenariusz awarii

Jeśli PGRST_DB_URI używa superusera postgres zamiast authenticator, każde zapytanie ma uprawnienia superusera, RLS jest nieskuteczne. Jeśli PGRST_DB_SCHEMAS jest puste, PostgREST eksponuje wszystkie schematy, łącznie z wewnętrznymi schematami Supabase (auth, _realtime, _analytics).

B5 - Realtime: subskrypcje WebSocket

Co robi ta usługa

Realtime umożliwia subskrypcje oparte na WebSocket na zmiany w bazie danych. Używa użytkownika supabase_admin i schematu _realtime.

Konfiguracja istotna dla bezpieczeństwa

realtime:
  container_name: realtime-dev.supabase-realtime   # Nazwa jest istotna dla 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          # ZMIENIĆ w produkcji
    API_JWT_SECRET: ${JWT_SECRET}
    SECRET_KEY_BASE: ${SECRET_KEY_BASE}   # min. 64 znaki
    SEED_SELF_HOST: "true"
    RUN_JANITOR: "true"

Co może pójść nie tak

DB_ENC_KEY: supabaserealtime to wartość domyślna. W produkcji musi zostać zmieniony. SECRET_KEY_BASE musi mieć co najmniej 64 znaki, w przeciwnym razie usługa nie uruchomi się lub będzie niebezpieczna. Realtime używa supabase_admin, co oznacza, że wewnętrznie ma pełny dostęp do bazy danych. Bezpieczeństwo leży w walidacji JWT: tylko uwierzytelnieni użytkownicy mogą otwierać subskrypcje, a RLS określa, które wiersze widzą.

Warunek weryfikowalny

# DB_ENC_KEY nie na wartości domyślnej?
grep "DB_ENC_KEY" docker-compose.yml
# NIE "supabaserealtime"

# SECRET_KEY_BASE wystarczająco długi?
SKB_LEN=$(grep "SECRET_KEY_BASE" .env | cut -d= -f2 | wc -c)
[ "$SKB_LEN" -lt 64 ] && echo "KRYTYCZNE: SECRET_KEY_BASE za krótki ($SKB_LEN znaków)"

# 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 i MinIO (S3)

Co robi ta usługa

Storage zarządza przesyłaniem i pobieraniem plików. Domyślnie przechowuje pliki lokalnie w wolumenie. W produkcji zalecany jest backend kompatybilny z S3 (MinIO self-hosted lub usługa S3 wolna od Cloud Act).

Lokalne Storage (domyślne)

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, dostosować
    ENABLE_IMAGE_TRANSFORMATION: "true"
    IMGPROXY_URL: http://imgproxy:5001

MinIO jako backend S3 (produkcja)

Jeśli używasz MinIO, dochodzi dodatkowa usługa:

# docker-compose.s3.yml (dodatkowo do głównego Compose)
minio:
  image: minio/minio:latest              # W produkcji przypiąć wersję
  restart: unless-stopped
  ports:
    - "127.0.0.1:9000:9000"              # API, TYLKO localhost
    - "127.0.0.1:9001:9001"              # Konsola, TYLKO localhost
  volumes:
    - minio-data:/data
  environment:
    MINIO_ROOT_USER: ${MINIO_ROOT_USER}
    MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD}   # min. 8 znaków
  command: server /data --console-address ":9001"

Następnie w konfiguracji Storage:

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

Warunek weryfikowalny

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

# MinIO niedostępne z zewnątrz (jeśli używane)?
ss -tlnp | grep 9000
# Oczekiwanie: 127.0.0.1:9000 (nie 0.0.0.0)

# MinIO domyślne poświadczenia?
grep "MINIO_ROOT" .env | grep -iE "minioadmin\|minio123\|admin"
# Oczekiwanie: brak trafień

# Wolumen Storage istnieje i ma dane?
ls -la volumes/storage/ 2>/dev/null | head -5

# Polityki bucketów Storage (przez MinIO Client)
# mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
# mc admin policy ls local

Scenariusz awarii

MinIO z domyślnymi poświadczeniami (minioadmin:minioadmin) na 0.0.0.0:9000 oznacza: każdy w internecie może czytać i zapisywać wszystkie pliki. Konsola MinIO na porcie 9001 daje dodatkowo interfejs webowy do pełnej administracji. Polityki bucketów Storage mogą być ustawione na “public”, co oznacza, że pliki są dostępne bez uwierzytelniania.

B7 - Analytics, Vector i Supavisor (usługi wewnętrzne)

Co robią te usługi

Te usługi nie są bezpośrednio dostępne z zewnątrz, ale są istotne z punktu widzenia bezpieczeństwa, ponieważ mają uprzywilejowany dostęp do bazy danych.

Analytics (Logflare): Zbiera i przechowuje logi wszystkich usług. Używa supabase_admin i schematu _analytics.

Vector: Pipeline logów przekierowujący logi Docker do Logflare. Ma dostęp do Docker Socket.

Supavisor: Connection Pooler dla PostgreSQL. Zarządza pulą połączeń i ma dostęp supabase_admin.

Konfiguracja istotna dla bezpieczeństwa

analytics:
  image: supabase/logflare:1.27.0
  ports:
    - "127.0.0.1:4000:4000"             # TYLKO 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 eksponuje port Postgres
    - "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}

Co może pójść nie tak

Vector z dostępem do Docker Socket (/var/run/docker.sock) może czytać logi kontenerów. Socket powinien być zamontowany Read-Only (:ro). Analytics na porcie 4000 z domyślnymi tokenami eksponuje logi wszystkich usług. Supavisor na 0.0.0.0:5432 zamiast wewnętrznego interfejsu czyni Connection Pooler (a tym samym PostgreSQL) dostępnym z zewnątrz.

Warunek weryfikowalny

# Analytics tylko wewnętrznie?
ss -tlnp | grep 4000
# Oczekiwanie: 127.0.0.1:4000

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

# Supavisor port tylko wewnętrznie?
ss -tlnp | grep 5432
# Oczekiwanie: 10.0.1.10:5432 (wewnętrzny interfejs)

# Tokeny Logflare nie na wartości domyślnej?
grep "LOGFLARE.*TOKEN" .env | grep -iE "your-\|change\|example"
# Oczekiwanie: brak trafień

B8 - Studio: zabezpieczanie dashboardu

Co robi ta usługa

Studio to interfejs webowy do zarządzania bazą danych, administracji użytkownikami i zapytań SQL. Poprzez SERVICE_ROLE_KEY i bezpośredni dostęp do Postgres ma pełny dostęp do wszystkiego.

Konfiguracja istotna dla bezpieczeństwa

Studio jest chronione przez Kong za pomocą Basic Auth (DASHBOARD_USERNAME/DASHBOARD_PASSWORD). Dodatkowo Studio w produkcji nie powinno w ogóle działać lub być dostępne tylko przez tunel SSH.

Opcja 1: Studio nie uruchamiać w produkcji (zalecane)

# W docker-compose.override.yml lub usunąć Studio z Compose
# Studio używać tylko lokalnie z supabase start do developmentu

Opcja 2: Studio tylko przez tunel SSH

# Ze stacji roboczej:
ssh -L 3000:localhost:3000 deploy@supabase-prod

# Następnie w przeglądarce: http://localhost:3000

Warunek weryfikowalny

# Kontener Studio działa? (W produkcji nie powinien działać)
docker compose ps | grep studio
# Zalecenie: nie running w produkcji

# Jeśli Studio działa: niedostępne z zewnątrz?
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 \
  "https://app.example.com:3000" 2>/dev/null
# Oczekiwanie: Timeout lub Connection Refused

# Hasło dashboardu silne?
DASH_LEN=$(grep "DASHBOARD_PASSWORD" .env | cut -d= -f2 | wc -c)
[ "$DASH_LEN" -lt 16 ] && echo "OSTRZEŻENIE: Hasło dashboardu za krótkie"

Scenariusz awarii

Studio ze słabym hasłem dashboardu i publicznym dostępem daje atakującemu kompletny interfejs administracyjny bazy danych. Może wykonywać zapytania SQL, usuwać użytkowników, dezaktywować RLS i eksportować dane. To najgorszy scenariusz dla self-hosted stacku.

Lista kontrolna usług

Po konfiguracji wszystkich usług, przed pierwszym użyciem produkcyjnym sprawdzić:

Sekrety
  [ ] Wszystkie sekrety wygenerowane (brak wartości domyślnych)
  [ ] JWT_SECRET co najmniej 32 znaki
  [ ] POSTGRES_PASSWORD co najmniej 24 znaki
  [ ] DASHBOARD_PASSWORD co najmniej 16 znaków
  [ ] SECRET_KEY_BASE co najmniej 64 znaki
  [ ] MINIO_ROOT_PASSWORD co najmniej 8 znaków (jeśli MinIO)
  [ ] DB_ENC_KEY zmieniony (nie "supabaserealtime")

PostgreSQL
  [ ] Port tylko na wewnętrznym interfejsie (10.0.1.10:5432)
  [ ] Skrypty Init niezmienione (roles.sql, jwt.sql itd.)
  [ ] Role poprawnie utworzone (anon nie jest superuserem)

Kong
  [ ] Port tylko na localhost (127.0.0.1:8000)
  [ ] JWT Validation aktywna na wszystkich trasach API
  [ ] Hasło dashboardu silne

GoTrue (Auth)
  [ ] JWT_EXP maksymalnie 3600
  [ ] AUTOCONFIRM false
  [ ] SMTP skonfigurowany i przetestowany
  [ ] SITE_URL i API_EXTERNAL_URL poprawne
  [ ] Rotacja Refresh Token aktywna
  [ ] DISABLE_SIGNUP ustawiony jeśli zamknięta aplikacja

PostgREST
  [ ] Używa roli authenticator (nie postgres)
  [ ] DB_SCHEMAS jawnie zdefiniowane

Realtime
  [ ] DB_ENC_KEY zmieniony
  [ ] SECRET_KEY_BASE co najmniej 64 znaki

Storage / MinIO
  [ ] MinIO niedostępne z zewnątrz
  [ ] Domyślne poświadczenia MinIO zmienione
  [ ] Uprawnienia wolumenu Storage poprawne

Usługi wewnętrzne
  [ ] Port Analytics tylko localhost
  [ ] Vector Docker Socket Read-Only
  [ ] Port Supavisor tylko wewnętrznie
  [ ] Tokeny Logflare nie na wartości domyślnej

Studio
  [ ] W produkcji dezaktywowane LUB tylko przez tunel SSH
  [ ] Hasło dashboardu silne

Część C - Konfiguracja stacku Supabase

Te kroki dotyczą właściwej instalacji Supabase i jej ustawień istotnych z punktu widzenia bezpieczeństwa.

C1 - Wersjonowanie deploymentu Supabase

Realizacja

Wszystkie pliki infrastrukturalne należą do repozytorium Git. Deploymenty odbywają się wyłącznie przez to repozytorium, nigdy przez ręczne zmiany na serwerze.

infra/
  docker-compose.yml
  .env.example            (szablon, bez prawdziwych sekretów)
  caddy/
    Caddyfile
  postgres/
    migrations/
  scripts/
    backup.sh
    restore.sh
    health-check.sh
    security-check.sh
  runbooks/
    supabase-production.md
    security-baseline.md

Workflow deploymentu

# Na serwerze
cd /opt/supabase
git pull origin main

# Załadowanie zmiennych środowiskowych (plik leży tylko na serwerze)
source .env

# Uruchomienie/aktualizacja stacku
docker compose up -d

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

Sprawdzalny warunek

# Czy na serwerze są uncommitted zmiany?
cd /opt/supabase && git status --porcelain

# Oczekiwanie: puste (brak lokalnych zmian)

# Czy serwer jest na aktualnym stanie?
git log --oneline -1
# Porównanie z Remote
git fetch origin && git diff HEAD origin/main --stat

# Oczekiwanie: brak różnic

Scenariusz awarii

Ręczne zmiany w docker-compose.yml na serwerze zostaną nadpisane przy następnym git pull lub spowodują konflikty merge. Co gorsza: nikt nie wie, jaka zmiana została dokonana kiedy i przez kogo. Po utracie serwera konfiguracja nie jest odtwarzalna.

C2 - docker-compose.yml: minimalny stack produkcyjny

Realizacja

Supabase dostarcza referencyjny plik Compose z ponad 400 liniami i ok. 15 serwisami. Nie wszystkie są potrzebne w produkcji. Oto decyzje istotne z punktu widzenia bezpieczeństwa:

Minimalny stack (te serwisy są potrzebne):

postgres          Baza danych
kong              API Gateway
gotrue            Auth
postgrest         REST API
realtime          WebSocket (jeśli potrzebny)
storage           File Storage (jeśli potrzebny)
meta              Metadane dla PostgREST

Nie w produkcji (pominąć lub tylko wewnętrznie):

studio            Admin UI, tylko przez tunel SSH lub VPN
imgproxy          tylko jeśli potrzebne transformacje obrazów
inbucket          tylko do lokalnych testów e-mail

Fragment konfiguracji istotnej z punktu widzenia bezpieczeństwa:

# docker-compose.yml (fragment, części istotne dla bezpieczeństwa)

services:
  postgres:
    image: supabase/postgres:15.6.1.143    # Wersja przypięta
    restart: unless-stopped
    ports:
      - "10.0.1.10:5432:5432"              # TYLKO wewnętrzny interfejs
    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                       # Wersja przypięta
    restart: unless-stopped
    ports:
      - "127.0.0.1:8000:8000"              # TYLKO localhost (Reverse Proxy przed nim)
    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         # Wersja przypięta
    restart: unless-stopped
    environment:
      GOTRUE_JWT_SECRET: ${JWT_SECRET}
      GOTRUE_JWT_EXP: 3600                  # 1 godzina, nie więcej
      GOTRUE_EXTERNAL_EMAIL_ENABLED: true
      GOTRUE_MAILER_AUTOCONFIRM: false      # Wymuszenie potwierdzenia e-mail
      GOTRUE_DISABLE_SIGNUP: false           # ustawić na true jeśli rejestracja zamknięta
      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      # Wersja przypięta
    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

Krytyczne punkty konfiguracji:

GOTRUE_JWT_EXP: 3600         nie wyżej niż 3600 (1 godz.)
GOTRUE_MAILER_AUTOCONFIRM    false w produkcji
GOTRUE_DISABLE_SIGNUP         true jeśli brak otwartej rejestracji
REFRESH_TOKEN_ROTATION        true (zapobiega ponownemu użyciu tokena)
Wersje obrazów                zawsze przypiąć, nigdy :latest
Port Postgres                 tylko na wewnętrznym interfejsie
Port Kong                     tylko na localhost (Reverse Proxy przed nim)

Sprawdzalny warunek

# Obrazy przypięte (żaden :latest)?
grep "image:" docker-compose.yml | grep -c "latest"
# Oczekiwanie: 0

# Postgres dostępny tylko wewnętrznie?
docker compose exec postgres ss -tlnp | grep 5432
# Oczekiwanie: tylko 10.0.1.10:5432 lub 0.0.0.0:5432 (wtedy sprawdzić firewall)

# Postgres niedostępny z zewnątrz?
nmap -p 5432 app.example.com
# Oczekiwanie: filtered lub closed

# JWT Expiry poprawny?
grep "GOTRUE_JWT_EXP" .env
# Oczekiwanie: 3600 lub mniej

# Supabase Studio niedostępne z zewnątrz?
curl -s -o /dev/null -w "%{http_code}" https://app.example.com:3000
# Oczekiwanie: Timeout lub Connection Refused

Scenariusz awarii

Nieprzypięte obrazy (image: supabase/gotrue:latest) mogą przy docker compose pull niepostrzeżenie wprowadzić nową wersję zawierającą breaking changes lub znaną podatność. Jeśli Postgres nasłuchuje na 0.0.0.0:5432 i firewall tymczasowo przestanie działać, cała baza danych jest dostępna w internecie. Jeśli GOTRUE_JWT_EXP jest ustawiony na 86400 (24 godz.), skradziony token jest ważny przez cały dzień.

C3 - Kompletne i bezpieczne zarządzanie sekretami

Realizacja

Stack Supabase ma co najmniej te sekrety:

# .env (tylko na serwerze, nigdy w Git)

# Sekrety podstawowe
JWT_SECRET=                    # min. 32 znaki, wygenerowane przez openssl rand -base64 32
ANON_KEY=                      # Token JWT z rolą anon
SERVICE_ROLE_KEY=              # Token JWT z rolą service_role, omija RLS
POSTGRES_PASSWORD=             # min. 24 znaki, wygenerowane

# Dashboard
DASHBOARD_USERNAME=            # Login do Supabase Studio
DASHBOARD_PASSWORD=            # min. 16 znaków

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

# Storage (jeśli backend S3)
S3_ACCESS_KEY=
S3_SECRET_KEY=

Generowanie sekretów:

# JWT Secret
openssl rand -base64 32

# Hasło Postgres
openssl rand -base64 24

# Klucze Anon i Service Role (Supabase CLI)
# Lub ręczne tworzenie JWT z JWT_SECRET

Zarządzanie sekretami na serwerze:

# Plik .env z restrykcyjnymi uprawnieniami
chmod 600 /opt/supabase/.env
chown deploy:deploy /opt/supabase/.env

# Sprawdzenie, czy .env nie jest w Git
cat /opt/supabase/.gitignore | grep ".env"

W repozytorium leży tylko szablon:

# .env.example (w 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=

Ta sama zasada obowiązuje przy każdym aspekcie bezpieczeństwa danych w infrastrukturze AI dla przedsiębiorstw.

Sprawdzalny warunek

# .env nie jest w Git?
cd /opt/supabase && git ls-files .env
# Oczekiwanie: puste

# .env w .gitignore?
grep "^\.env$" .gitignore
# Oczekiwanie: .env

# Uprawnienia pliku poprawne?
stat -c "%a %U" .env
# Oczekiwanie: 600 deploy

# Sekrety mają wystarczającą długość?
awk -F= '{if (length($2) < 16 && $2 != "" && $1 !~ /PORT|HOST|NAME/) print "ZA KROTKIE: "$1}' .env
# Oczekiwanie: brak wyjścia

# Brak domyślnych haseł?
grep -iE "password|secret" .env | grep -iE "change.me|default|example|your.*here"
# Oczekiwanie: brak trafień

Scenariusz awarii

Najczęstszym problemem bezpieczeństwa przy Supabase Self-Hosting nie jest exploit serwerowy, lecz wyciek sekretu. Jeśli .env zostanie commitnięty do Git, a repozytorium jest publiczne (lub stanie się publiczne), wszystkie sekrety leżą otwarte. Z kluczem SERVICE_ROLE_KEY można odczytywać i zapisywać całą bazę danych bez RLS.

C4 - Sprawdzenie polityk bazodanowych

Realizacja

W Supabase duża część bezpieczeństwa leży w PostgreSQL Row Level Security (RLS), a nie w serwerze aplikacyjnym. Każda tabela w schemacie public musi mieć aktywne RLS.

Wyszukiwanie tabel bez RLS:

-- Wszystkie tabele public bez RLS
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
  AND rowsecurity = false;

Wyszukiwanie tabel z RLS ale bez polityk:

-- RLS aktywne, ale brak zdefiniowanej polityki = brak dostępu
-- (może być zamierzone, ale należy sprawdzić)
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;

Wyszukiwanie zbyt otwartych polityk:

-- Polityki dające wszystkim rolom pełny dostęp
SELECT tablename, policyname, permissive, roles, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
  AND (roles = '{public}' OR qual = 'true');

Sprawdzenie użycia Service Role:

-- Jakie role istnieją i jakie mają uprawnienia?
SELECT rolname, rolsuper, rolcreaterole, rolcreatedb
FROM pg_roles
WHERE rolname IN ('anon', 'authenticated', 'service_role', 'authenticator');

Sprawdzalny warunek

# Jako zautomatyzowany skrypt z 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;
\""

# Oczekiwanie: brak tabel (lub tylko świadomie wyłączone)

Scenariusz awarii

Tabela users z rowsecurity = false jest przez PostgREST API w pełni odczytywalna dla każdego z kluczem anon. Dotyczy to wszystkich kolumn, w tym adresów e-mail, numerów telefonów i innych danych osobowych. Wystarczy proste polecenie curl z publicznym kluczem anon.

Część D - Eksploatacja i monitoring

Te kroki działają regularnie i zautomatyzowanie.

D1 - Automatyzacja kopii zapasowych

Realizacja

Codzienne dumpy PostgreSQL, zaszyfrowane i przechowywane zewnętrznie.

#!/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

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

# Kopia zapasowa bucketów Storage (jeśli Supabase Storage jest używany)
docker compose exec -T storage tar -czf - /var/lib/storage \
  > "${BACKUP_DIR}/storage_${DATE}.tar.gz"

# Szyfrowanie
for file in "${BACKUP_DIR}"/*_${DATE}.*; do
  gpg --encrypt --recipient "${GPG_RECIPIENT}" "$file"
  rm "$file"   # Usunięcie niezaszyfrowanej wersji
done

# Kopiowanie na zewnętrzny serwer (audit-runner lub S3)
rsync -az "${BACKUP_DIR}/"*_${DATE}*.gpg \
  deploy@10.0.1.11:/opt/backup-archive/

# Usuwanie starych kopii zapasowych (lokalnie)
find "${BACKUP_DIR}" -name "*.gpg" -mtime +${RETENTION_DAYS} -delete

# Na serwerze backupowym również porządkowanie
ssh deploy@10.0.1.11 \
  "find /opt/backup-archive -name '*.gpg' -mtime +${RETENTION_DAYS} -delete"

echo "Kopia zapasowa ${DATE} zakończona"
# Konfiguracja zadania Cron
# crontab -e
0 3 * * * /opt/supabase/scripts/backup.sh >> /var/log/backup.log 2>&1

Sprawdzalny warunek

# Kopia zapasowa z dzisiaj istnieje?
ls -la /opt/backups/*_$(date +%Y-%m-%d)*.gpg

# Kopia zapasowa dotarła na zewnętrzny serwer?
ssh deploy@10.0.1.11 "ls -la /opt/backup-archive/*_$(date +%Y-%m-%d)*.gpg"

# Rozmiar kopii zapasowej wiarygodny (nie 0 bajtów)?
find /opt/backups -name "*.gpg" -size 0 -print
# Oczekiwanie: brak trafień

Scenariusz awarii

Przechowywanie kopii zapasowych tylko na tym samym serwerze oznacza: jeśli serwer ulegnie awarii lub zostanie zaszyfrowany (ransomware), kopie zapasowe również przepadają. Niezaszyfrowane kopie zapasowe na zewnętrznym serwerze stanowią wyciek danych, ponieważ dump zawiera wszystkie dane tabel w postaci jawnej.

D2 - Regularne testowanie przywracania

Realizacja

Raz w miesiącu przywrócić kopię zapasową na systemie testowym.

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

set -euo pipefail

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

# Odszyfrowanie
gpg --decrypt "$BACKUP_FILE" > /tmp/restore-test.dump

# Uruchomienie kontenera testowego
docker run -d --name restore-test \
  -e POSTGRES_PASSWORD=testpassword \
  supabase/postgres:15.6.1.143

sleep 10

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

# Sprawdzenie tabel
docker exec restore-test psql -U postgres -c \
  "SELECT schemaname, tablename FROM pg_tables WHERE schemaname = 'public';"

# Sprawdzenie liczby wierszy
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;"

# Porządkowanie
docker rm -f restore-test
rm /tmp/restore-test.dump

echo "Test przywracania zakończony"

Sprawdzalny warunek

# Uruchomienie skryptu testu przywracania i sprawdzenie kodu wyjścia
./scripts/restore-test.sh /opt/backup-archive/db_latest.dump.gpg
echo $?
# Oczekiwanie: 0

# Sprawdzenie logu ostatniego testu przywracania
cat /var/log/restore-test.log | tail -20

Scenariusz awarii

Wiele zespołów ma kopie zapasowe, które działają od miesięcy, ale nigdy nie testowało przywracania. Typowe problemy: niewłaściwy format pg_dump (Plain Text zamiast Custom), brakujące uprawnienia przy przywracaniu, niekompatybilne wersje PostgreSQL między kopią zapasową a przywróceniem. Wszystko to ujawnia się dopiero wtedy, gdy przywrócenie jest naprawdę potrzebne.

D3 - Codzienne kontrole infrastruktury

Realizacja

Skrypt na audit-runner sprawdza codziennie stan systemu produkcyjnego.

#!/bin/bash
# scripts/security-check.sh (działa na audit-runner)

set -euo pipefail

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

# 1. Status kontenerów
STOPPED=$(ssh deploy@${PROD_HOST} "docker compose ps --format json" | \
  jq -r 'select(.State != "running") | .Name')
if [ -n "$STOPPED" ]; then
  REPORT+="KRYTYCZNE: Kontenery nie działają: ${STOPPED}\n"
  CRITICAL=1
fi

# 2. Otwarte porty z zewnątrz
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+="KRYTYCZNE: Nieoczekiwane otwarte porty: ${OPEN_PORTS}\n"
  CRITICAL=1
fi

# 3. Data wygaśnięcia certyfikatu
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+="OSTRZEŻENIE: Certyfikat TLS wygasa za ${DAYS_LEFT} dni\n"
fi

# 4. Dryft firewalla
FIREWALL_DIFF=$(ssh deploy@${PROD_HOST} "iptables-save" | \
  diff /opt/baselines/firewall-baseline.txt - || true)
if [ -n "$FIREWALL_DIFF" ]; then
  REPORT+="OSTRZEŻENIE: Firewall uległ zmianie:\n${FIREWALL_DIFF}\n"
fi

# 5. Wersje obrazów Docker (dryft wobec baseliny)
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+="OSTRZEŻENIE: Wersje kontenerów uległy zmianie:\n${IMAGE_DIFF}\n"
fi

# 6. Przestrzeń dyskowa
DISK_USAGE=$(ssh deploy@${PROD_HOST} "df -h / | tail -1 | awk '{print \$5}' | tr -d '%'")
if [ "$DISK_USAGE" -gt 85 ]; then
  REPORT+="OSTRZEŻENIE: Użycie dysku na poziomie ${DISK_USAGE}%\n"
fi

# 7. Status kopii zapasowej
LAST_BACKUP=$(ssh deploy@${PROD_HOST} "ls -t /opt/backups/*.gpg 2>/dev/null | head -1")
if [ -z "$LAST_BACKUP" ]; then
  REPORT+="KRYTYCZNE: Nie znaleziono kopii zapasowej\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+="OSTRZEŻENIE: Ostatnia kopia zapasowa ma ${BACKUP_AGE} godzin\n"
  fi
fi

# 8. Sprawdzenie RLS
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+="OSTRZEŻENIE: ${UNPROTECTED} tabel bez RLS\n"
fi

# Wynik
echo "=== Kontrola bezpieczeństwa $(date) ==="
if [ -n "$REPORT" ]; then
  echo -e "$REPORT"
else
  echo "Wszystkie kontrole zaliczone"
fi

# Przy krytycznych ustaleniach: wysłanie alertu
if [ "$CRITICAL" -eq 1 ]; then
  echo -e "$REPORT" | mail -s "KRYTYCZNE: Kontrola bezpieczeństwa $(date)" ops@example.com
fi
# Cron na audit-runner
0 7 * * * /opt/audit/scripts/security-check.sh >> /var/log/security-check.log 2>&1

Sprawdzalny warunek

# Czy skrypt kontroli działał dzisiaj?
grep "$(date +%Y-%m-%d)" /var/log/security-check.log | tail -1
# Oczekiwanie: wpis z dzisiaj

# Wynik?
grep "Wszystkie kontrole zaliczone\|KRYTYCZNE\|OSTRZEŻENIE" /var/log/security-check.log | tail -5

D4 - Claude Code jako kontekstowa warstwa analizy

Claude Code analizuje wyniki deterministycznych kontroli i rozpoznaje związki, których skrypty nie widzą.

Architektura

Codzienne kontrole (D3)
     |
     +-- Deterministyczne ustalenia
     |   (otwarte porty, dryft firewalla, brakujące kopie zapasowe)
     |
     +---> Cotygodniowy przegląd Claude Code
          |
          +-- Pliki konfiguracyjne (docker-compose.yml, Caddyfile, .env.example)
          +-- Logi kontroli bezpieczeństwa z ostatnich 7 dni
          +-- Git Diff zmian infrastrukturalnych
          +-- Eksport polityk RLS
          |
          +---> Priorytetyzowany raport
               |
               +---> Decyzja DevOps (człowiek)

Konkretny skrypt

#!/bin/bash
# scripts/claude-review-prep.sh (działa na 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'
# Cotygodniowy przegląd bezpieczeństwa - dane wejściowe

## Kontekst
Self-hosted stack Supabase na Hetzner Cloud.
Architektura: Reverse Proxy -> Kong -> Supabase Services -> PostgreSQL
System audytowy na oddzielnym serwerze.
HEADER

# Logi kontroli bezpieczeństwa z ostatnich 7 dni
echo -e "\n## Wyniki kontroli bezpieczeństwa (ostatnie 7 dni)\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
grep -A 20 "Security Check" /var/log/security-check.log | \
  tail -100 >> "$OUTPUT"
echo '```' >> "$OUTPUT"

# Aktualna konfiguracja Docker Compose (bez sekretów)
echo -e "\n## Aktualna 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 z ostatniego tygodnia
echo -e "\n## Zmiany infrastrukturalne (ostatnie 7 dni)\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"

# Status RLS
echo -e "\n## Status RLS\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"

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

echo "Dane wejściowe do przeglądu utworzone: $OUTPUT"

Czego Claude Code nie robi

Claude NIE wykonuje automatycznych zmian na produkcji.
Claude NIE deployuje.
Claude NIE rotuje sekretów.
Claude NIE ma bezpośredniego dostępu do serwera produkcyjnego.

Claude analizuje dane, które mu przekazano,
i tworzy raporty do decyzji podejmowanych przez ludzi.

Lista kontrolna deploymentu

Przed pierwszym uruchomieniem i po większych zmianach:

Część A - Infrastruktura
  [ ] Dwa oddzielne serwery (prod + audit)
  [ ] Sieć prywatna skonfigurowana i przetestowana
  [ ] Cloud Firewall aktywny (tylko 443, SSH z IP administratora)
  [ ] Host Firewall aktywny (iptables)
  [ ] Baselina firewalla zapisana
  [ ] SSH: tylko klucze, bez root, bez hasła
  [ ] Reverse Proxy skonfigurowany (Caddy/Nginx)
  [ ] TLS aktywny z automatycznym odnawianiem
  [ ] Nagłówki bezpieczeństwa ustawione (HSTS, CSP, X-Frame-Options)
  [ ] Proxy WebSocket dla Realtime skonfigurowane

Część B - Usługi Supabase
  Sekrety (B0)
    [ ] Wszystkie sekrety wygenerowane (brak wartości domyślnych)
    [ ] JWT_SECRET co najmniej 32 znaki
    [ ] POSTGRES_PASSWORD co najmniej 24 znaki
    [ ] DASHBOARD_PASSWORD co najmniej 16 znaków
    [ ] SECRET_KEY_BASE co najmniej 64 znaki
    [ ] MINIO_ROOT_PASSWORD co najmniej 8 znaków (jeśli MinIO)
    [ ] DB_ENC_KEY zmieniony (nie "supabaserealtime")
  PostgreSQL (B1)
    [ ] Port tylko na wewnętrznym interfejsie (10.0.1.10:5432)
    [ ] Skrypty Init niezmienione (roles.sql, jwt.sql itd.)
    [ ] Role poprawnie utworzone (anon nie jest superuserem)
  Kong (B2)
    [ ] Port tylko na localhost (127.0.0.1:8000)
    [ ] JWT Validation aktywna na wszystkich trasach API
    [ ] Hasło dashboardu silne
  GoTrue (B3)
    [ ] JWT_EXP maksymalnie 3600
    [ ] AUTOCONFIRM false
    [ ] SMTP skonfigurowany i przetestowany
    [ ] SITE_URL i API_EXTERNAL_URL poprawne
    [ ] Rotacja Refresh Token aktywna
    [ ] DISABLE_SIGNUP ustawiony jeśli zamknięta aplikacja
  PostgREST (B4)
    [ ] Używa roli authenticator (nie postgres)
    [ ] DB_SCHEMAS jawnie zdefiniowane
  Realtime (B5)
    [ ] DB_ENC_KEY zmieniony
    [ ] SECRET_KEY_BASE co najmniej 64 znaki
  Storage / MinIO (B6)
    [ ] MinIO niedostępne z zewnątrz
    [ ] Domyślne poświadczenia MinIO zmienione
    [ ] Uprawnienia wolumenu Storage poprawne
  Usługi wewnętrzne (B7)
    [ ] Port Analytics tylko localhost
    [ ] Vector Docker Socket Read-Only
    [ ] Port Supavisor tylko wewnętrznie
    [ ] Tokeny Logflare nie na wartości domyślnej
  Studio (B8)
    [ ] W produkcji dezaktywowane LUB tylko przez tunel SSH
    [ ] Hasło dashboardu silne

Część C - Konfiguracja stacku
  [ ] docker-compose.yml wersjonowany w Git
  [ ] Wszystkie wersje obrazów przypięte (żaden :latest)
  [ ] .env nie jest w Git
  [ ] Uprawnienia pliku .env 600
  [ ] .env.example w Git jako szablon
  [ ] Brak domyślnych haseł
  [ ] RLS aktywne na wszystkich tabelach public
  [ ] Brak tabel bez polityk (chyba że świadomie)
  [ ] Brak zbyt otwartych polityk (qual = 'true')

Część D - Eksploatacja i monitoring
  [ ] Codzienny job kopii zapasowej aktywny
  [ ] Kopie zapasowe zaszyfrowane
  [ ] Kopie zapasowe przechowywane zewnętrznie (audit-runner lub S3)
  [ ] Test przywracania przeprowadzony co najmniej raz
  [ ] Strategia retencji skonfigurowana
  [ ] Codzienny job kontroli bezpieczeństwa na audit-runner
  [ ] Alerting przy krytycznych ustaleniach
  [ ] Cotygodniowy przegląd Claude Code skonfigurowany

Część E - Aktualizacje i konserwacja
  [ ] Unattended Upgrades zainstalowane i aktywne
  [ ] Interwał automatycznych aktualizacji ustawiony na codziennie
  [ ] Obrazy Supabase przypięte (bez :latest)
  [ ] Żaden obraz Supabase nie starszy niż 90 dni
  [ ] Commit aktualizacji w ciągu ostatnich 45 dni
  [ ] Skrypt auto-patch dla PostgreSQL minor patches aktywny
  [ ] Cron auto-patch PO cron backup (03:00 po 02:00)
  [ ] Monitor wydań bezpieczeństwa na audit-runner aktywny (codziennie)
  [ ] Trivy zainstalowane na audit-runner
  [ ] Cron sprawdzania konserwacji na audit-runner (cotygodniowo poniedziałek)
  [ ] Certyfikat TLS ważny co najmniej 14 dni
  [ ] Wykorzystanie dysku poniżej 85%

Część E - Aktualizacje i konserwacja

Stack self-hosted, który nie jest regularnie aktualizowany, gromadzi luki bezpieczeństwa. Niezałatane CVE w PostgreSQL, Kong lub GoTrue to realne wektory ataku. Jednocześnie aktualizacje mogą wprowadzać Breaking Changes, które unieruchamiają stack.

Dlatego proces aktualizacji potrzebuje jasnych reguł: co jest aktualizowane kiedy, jak jest testowane i jak zapewnić, że nic nie zostanie pominięte.

E1 - Trzy warstwy aktualizacji

Stack ma trzy niezależne warstwy aktualizacji o różnych rytmach i ryzykach.

Warstwa 1: Poziom OS (Ubuntu)
   │  Co: Kernel, pakiety systemowe, OpenSSL, Docker Engine
   │  Rytm: Cotygodniowe łatki bezpieczeństwa, comiesięczna pełna aktualizacja
   │  Ryzyko: Niskie (apt upgrade jest stabilne)
   │  Metoda: apt update && apt upgrade

Warstwa 2: Usługi Supabase (obrazy Docker)
   │  Co: PostgreSQL, Kong, GoTrue, PostgREST, Realtime, Storage, etc.
   │  Rytm: Comiesięcznie (cykl wydań Supabase)
   │  Ryzyko: Średnie do wysokiego (Breaking Changes między wersjami)
   │  Metoda: Zmiana tagów obrazów w docker-compose.yml, pull, restart

Warstwa 3: Reverse Proxy i narzędzia
   │  Co: Caddy, iptables, GPG, nmap, jq
   │  Rytm: Przy Security Advisories lub kwartalnie
   │  Ryzyko: Niskie
   │  Metoda: apt upgrade (Caddy przez własne repo)

Sprawdzalny warunek:

# 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

Scenariusz awarii: Niezałatany PostgreSQL ze znaną podatnością Remote Code Execution (jak CVE-2023-5869) może zostać wykorzystany przez atakującego, nawet jeśli RLS jest poprawnie skonfigurowany. Przestarzały Kong ze znaną podatnością Auth-Bypass może obejść walidację JWT.

E2 - Aktualizacje na poziomie OS

Cotygodniowo: łatki bezpieczeństwa (automatycznie)

# 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

Comiesięcznie: pełna aktualizacja systemu (ręcznie, ze sprawdzeniem)

# 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

Sprawdzalny warunek:

# 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 - Aktualizacje usług Supabase

Supabase publikuje mniej więcej co miesiąc nowe obrazy Docker. Proces aktualizacji musi przebiegać w sposób kontrolowany, ponieważ Breaking Changes między wersjami są możliwe.

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

Sprawdzenie aktualnych wersji vs. dostępnych:

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"

Bezpieczna procedura aktualizacji:

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 w przypadku problemów:

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

Sprawdzalny warunek:

# 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

Scenariusz awarii: Supabase GoTrue v2.170.0 miał zmianę w obsłudze Refresh Token, która złamała starsze klienty. Bez wcześniejszego przeczytania Release Notes i bez kopii zapasowej oznaczałoby to awarię. Aktualizacje Major Version PostgreSQL (np. 15 -> 16) wymagają cyklu pg_dump/pg_restore, sama zmiana tagu obrazu nie wystarczy.

E3b - Automatyczne łatanie (Warstwa 1)

Nie wszystkie aktualizacje wymagają człowieka. Drobne łatki PostgreSQL i aktualizacje Caddy są niskoryzyczne i mogą być wdrażane automatycznie w nocy.

Model dwuwarstwowy:

WARSTWA 1 - AUTOMATYCZNIE (auto-patch.sh, codziennie 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

WARSTWA 2 - RĘCZNIE (/supabase-update, w ciągu 24h po alercie):
  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)

Skrypt auto-patch.sh działa codziennie o 03:00 (po kopii zapasowej o 02:00) i:

  1. Sprawdza, czy istnieje aktualna kopia zapasowa (przerwanie, jeśli nie)
  2. Zabezpiecza bieżący stan (docker-compose.yml, wersje obrazów)
  3. Sprawdza, czy dostępny jest nowy obraz PostgreSQL Minor
  4. Wdraża go i wykonuje Health Check
  5. W przypadku błędu: automatyczny rollback do poprzedniej wersji
  6. Wysyła Info-Mail (sukces) lub Alarm-Mail (błąd)

Sprawdzalny warunek:

# 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

Harmonogram Cron (serwer prod):

02:00 codziennie  -> Backup (DB + Storage + zewnętrznie)
03:00 codziennie  -> Auto-Patch (PostgreSQL Minor + Caddy)
04:00 miesięcznie -> Test przywracania

E4 - Harmonogram aktualizacji i odpowiedzialności

Cotygodniowo (automatycznie):
  [ ] OS Security Patches (unattended-upgrades)
  [ ] Audit-Runner sprawdza, czy łatki zostały zastosowane

Comiesięcznie (ręcznie, zaplanowane):
  [ ] Sprawdzenie Supabase Release Notes
  [ ] Ocena nowych tagów obrazów
  [ ] Utworzenie kopii zapasowej
  [ ] Przeprowadzenie aktualizacji
  [ ] Health Checks
  [ ] Aktualizacja baselines

Kwartalnie (przegląd):
  [ ] Sprawdzenie wersji Caddy
  [ ] Sprawdzenie wersji Docker Engine
  [ ] Sprawdzenie wersji Node.js (dla Claude Code na audit-runner)
  [ ] Ocena Major Version PostgreSQL
  [ ] Czy cały toolchain jest aktualny?

Przy Security Advisories (natychmiast):
  [ ] Czy CVE dotyczy naszego stacku?
  [ ] Identyfikacja dotkniętego obrazu/pakietu
  [ ] Czy łatka jest dostępna?
  [ ] Przeprowadzenie awaryjnej aktualizacji

Sprawdzalny warunek:

# 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 - Monitor wydań bezpieczeństwa (codziennie)

Największym martwym punktem w self-hostingu nie jest konfiguracja początkowa, lecz przeoczenie łatek bezpieczeństwa. Gdy Supabase GoTrue publikuje poprawkę Auth-Bypass, zespół musi zareagować w ciągu 24 godzin, a nie po tygodniu.

Na audit-runner działa codziennie skrypt, który sprawdza wydania GitHub wszystkich komponentów Supabase i przy wydaniach bezpieczeństwa natychmiast alarmuje e-mailem.

Sprawdzane komponenty:

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

Trzy warstwy sprawdzania:

Warstwa 1: GitHub Releases
  -> Czy jest nowa wersja?
  -> Czy Release Notes zawierają "security"/"CVE"?

Warstwa 2: Trivy Container Scan
  -> Skanuje każdy działający obraz Docker przeciwko NVD/GitHub Advisories
  -> Znajduje CVE we wszystkich zależnościach (pakiety OS, biblioteki)

Warstwa 3: OSV API
  -> Sprawdza podatności na poziomie aplikacji dla GoTrue, PostgREST etc.
  -> Uzupełnia Trivy o CVE specyficzne dla pakietów

Jak to działa:

Codziennie 07:00 (audit-runner Cron)

   ├── Pobranie aktualnych wersji z serwera prod
   ├── GitHub API: Sprawdzenie najnowszych wydań dla każdego komponentu
   ├── Skanowanie Release Notes pod kątem "security", "CVE", "vulnerability"

   ├── Znaleziono wydanie bezpieczeństwa?
   │     -> NATYCHMIAST e-mail do ops@
   │     -> "Wymagane działanie w ciągu 24h"

   └── Znaleziono normalne wydanie?
         -> Cotygodniowe podsumowanie (poniedziałek)

Mechanizm cache: Skrypt zapamiętuje zgłoszone wydania, dzięki czemu ten sam e-mail nie przychodzi codziennie. Dopiero przy NOWYM wydaniu następuje ponowny alarm.

Sprawdzalny warunek:

# 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"

Scenariusz awarii: W styczniu 2024 opublikowano CVE-2023-5869 dla PostgreSQL (Remote Code Execution). Kto nie miał monitora wydań i sprawdzał aktualizacje tylko co miesiąc, był podatny przez 3-4 tygodnie. Z codziennym monitorem e-mail przyszedłby dzień po wydaniu.

E6 - Audit-Runner jako strażnik aktualizacji

Audit-Runner monitoruje, czy aktualizacje są przeprowadzane, i informuje zespół, gdy coś jest zaległe.

Na audit-runner działa cotygodniowy skrypt, który sprawdza:

#!/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 na 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

Kiedy Claude informuje:

Claude Code na audit-runner wysyła powiadomienia w trzech przypadkach:

NATYCHMIAST (e-mail do ops@):
  - Unattended Upgrades nieaktywne
  - Certyfikat TLS < 14 dni
  - Aktualizacja bezpieczeństwa zaległa > 3 dni

COTYGODNIOWO (raport konserwacji):
  - Wymagany restart
  - apt update zaległy
  - Obrazy Supabase > 60 dni

COMIESIĘCZNIE (przypomnienie o aktualizacji):
  - Supabase Release Notes niesprawdzone (brak commitu aktualizacji > 45 dni)
  - Przegląd kwartalny wymagany

Podsumowanie

Samodzielne hostowanie Supabase jest stosunkowo proste. Bezpieczna eksploatacja Supabase wymaga jasnych reguł architektonicznych i zautomatyzowanej kontroli.

Ten runbook rozdziela decyzje infrastrukturalne (Część A), architekturę i zabezpieczanie usług (Część B), konfigurację stacku (Część C), bieżący monitoring (Część D) i procesów aktualizacji (Część E). Połączenie deterministycznych kontroli i kontekstowej analizy Claude Code obejmuje zarówno znane wzorce, jak i nieoczekiwane ryzyka.

Kto te zasady stosuje od początku, buduje architekturę Cert-Ready by Design i oszczędza sobie późniejszych rund audytowych.

Przypomnienie: Konfiguracje specyficzne dla Hetzner (vSwitch, Cloud Firewall, nazwy interfejsów) można bezpośrednio przenieść na innych dostawców: OVH (PL), Oktawave (PL), IONOS (EU). Zasady architektoniczne obowiązują niezależnie od dostawcy.

Pobierz listę kontrolną audytu

Przygotowany prompt dla Claude Code. Prześlij plik na swój serwer i uruchom Claude Code w katalogu projektu stacku Supabase. Claude Code automatycznie sprawdzi wszystkie punkty bezpieczeństwa z tego runbooka i zgłosi ZALICZONY, OSTRZEŻENIE lub KRYTYCZNY.

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

Pobierz listę kontrolną

Spis treści serii

Ten artykuł jest częścią naszej serii DevOps dla self-hosted stacków aplikacyjnych.

  1. Supabase Self-Hosting Runbook ← ten artykuł
  2. Next.js nad Supabase - bezpieczna eksploatacja
  3. Supabase Edge Functions - bezpieczne wdrożenie
  4. Bezpieczna obsługa Trigger.dev Background Jobs
  5. Claude Code jako kontrola bezpieczeństwa w DevOps
  6. Security Baseline dla całego stacku

W następnym artykule pokażemy, jak bezpiecznie eksploatować Next.js nad Supabase, unikając typowych błędów przy Server Actions, obsłudze uwierzytelniania i dostępie do API.

Bert Gogolin

Bert Gogolin

Dyrektor Generalny, Gosign

AI Governance Briefing

Enterprise AI, regulacje i infrastruktura - raz w miesiącu, bezpośrednio ode mnie.

Bez spamu. Możliwość rezygnacji w każdej chwili. Polityka prywatności

Supabase Self-Hosting DevOps Security PostgreSQL
Udostępnij artykuł

Najczęściej zadawane pytania

Z jakich komponentów składa się stack Supabase Self-Hosting?

Stack Supabase składa się z PostgreSQL, PostgREST API, GoTrue Auth, serwera Realtime, Storage, Kong API Gateway i Supabase Studio. Opcjonalnie dochodzą Edge Functions. Oznacza to, że eksploatujemy kompletną platformę backendową, a nie tylko bazę danych.

Dlaczego do Supabase Self-Hosting potrzeba dwóch serwerów?

Rozdzielenie systemu produkcyjnego i audytowego zapobiega sytuacji, w której skompromitowany serwer jednocześnie manipuluje własnymi kontrolami bezpieczeństwa. System audytowy pozostaje niezależny i może niezawodnie wykrywać dryft konfiguracji.

Czy ten artykuł jest częścią serii?

Tak. Ten runbook to część 1 sześcioczęściowej serii DevOps dla self-hosted stacków aplikacyjnych. Seria obejmuje Supabase, Next.js, Edge Functions, Trigger.dev, Claude Code jako kontrolę bezpieczeństwa oraz Security Baseline.