Supabase Self-Hosting Runbook: bezpieczna architektura
Supabase Self-Hosting Runbook: konfiguracja Hetzner, Docker Compose, architektura usług, sekrety, RLS i backupy.
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.
- Supabase Self-Hosting Runbook ← ten artykuł
- Next.js nad Supabase - bezpieczna eksploatacja
- Supabase Edge Functions - bezpieczne wdrożenie
- Bezpieczna obsługa Trigger.dev Background Jobs
- Claude Code jako kontrola bezpieczeństwa w DevOps
- 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ługa | Port | Funkcja | Krytyczna konfiguracja bezpieczeństwa |
|---|---|---|---|
| PostgreSQL | 5432 | Przechowywanie danych | RLS na wszystkich tabelach, listen_address tylko wewnętrzny |
| PostgREST | 3000 | REST API | Automatyczne wymuszanie RLS, role-based access |
| GoTrue | 9999 | Uwierzytelnianie | JWT expiry, email confirmation, refresh token rotation |
| Kong | 8000 | API Gateway | Rate limiting, routing, JWT validation |
| Realtime | 4000 | WebSocket | Autoryzacja kanałów, limity połączeń |
| Storage | 5000 | Object Storage | Bucket policies, limity rozmiaru plików |
| Studio | 3000 | Panel administracyjny | Dostę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 (typowoens10). 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:
- Sprawdza, czy istnieje aktualna kopia zapasowa (przerwanie, jeśli nie)
- Zabezpiecza bieżący stan (docker-compose.yml, wersje obrazów)
- Sprawdza, czy dostępny jest nowy obraz PostgreSQL Minor
- Wdraża go i wykonuje Health Check
- W przypadku błędu: automatyczny rollback do poprzedniej wersji
- 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.
- Supabase Self-Hosting Runbook ← ten artykuł
- Next.js nad Supabase - bezpieczna eksploatacja
- Supabase Edge Functions - bezpieczne wdrożenie
- Bezpieczna obsługa Trigger.dev Background Jobs
- Claude Code jako kontrola bezpieczeństwa w DevOps
- 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
Dyrektor Generalny, Gosign
AI Governance Briefing
Enterprise AI, regulacje i infrastruktura - raz w miesiącu, bezpośrednio ode mnie.