Supabase Self-Hosting Runbook: arquitetura segura
Supabase Self-Hosting Runbook: configuração Hetzner, Docker Compose, arquitetura de serviços, secrets, RLS e backups.
Fazer Self-Hosting do Supabase é tecnicamente relativamente simples. Operar o Supabase de forma segura e estável é consideravelmente mais complexo.
O motivo: o Supabase não é um banco de dados isolado, mas sim uma plataforma de backend completa. Um stack self-hosted do Supabase é tipicamente composto por vários componentes: PostgreSQL, PostgREST API, GoTrue Auth, Realtime Server, Storage, Kong API Gateway, Supabase Studio e, opcionalmente, Edge Functions.
Estatística: De acordo com o relatório do Supabase de 2025, mais de 68% das instâncias self-hosted não possuem RLS ativo em todas as tabelas públicas - tornando essa verificação a mais frequentemente negligenciada.
Com isso, você opera de fato uma plataforma de backend, não apenas um banco de dados. Considerações semelhantes também se aplicam ao Self-Hosting de modelos de linguagem.
Este runbook descreve um setup mínimo de produção que pode ser operado com segurança e ao mesmo tempo permanece automatizável. Cada passo contém uma implementação concreta, uma condição verificável e um cenário de falha.
Nota sobre o provedor: Este runbook utiliza a Hetzner Cloud como exemplo de infraestrutura, pois a Hetzner é amplamente utilizada na região DACH, oferece datacenters na Alemanha e apresenta uma boa relação custo-benefício. No entanto, os princípios de arquitetura são independentes do provedor. Os pontos específicos da Hetzner (vSwitch, Cloud Firewall API, Robot Panel) podem ser transferidos para provedores brasileiros: Locaweb Cloud VPC (BR), Magalu Cloud VPC (BR) - e europeus: OVH vRack (EU), Scaleway Private Networks (EU). Quando um passo for específico da Hetzner, indicaremos isso.
Resumo - Artigo 1 de 6 da série DevOps Runbook
- Arquitetura de dois servidores separa produção e auditoria
- Docker Compose com versões de imagens fixadas
- Sete serviços (PostgreSQL, PostgREST, GoTrue, Kong, Realtime, Storage, Studio)
- RLS em todas as tabelas públicas
- Backups criptografados diários armazenados externamente
Sumário da série
Este guia faz parte da nossa série de runbooks DevOps para stacks modernos de aplicações self-hosted.
- Supabase Self-Hosting Runbook - este artigo
- Next.js sobre Supabase com segurança
- Supabase Edge Functions com segurança
- Trigger.dev Background Jobs em produção segura
- Claude Code como controle de segurança no workflow DevOps
- Security Baseline para todo o stack
O artigo 1 abrange infraestrutura, serviços e configuração do stack. Os artigos seguintes são construídos sobre ele.
Visão geral dos serviços
| Serviço | Porta | Função | Configuração crítica de segurança |
|---|---|---|---|
| PostgreSQL | 5432 | Armazenamento de dados | RLS em todas as tabelas, listen_address apenas interno |
| PostgREST | 3000 | REST API | Aplicação automática de RLS, acesso baseado em roles |
| GoTrue | 9999 | Autenticação | JWT expiry, confirmação por email, refresh token rotation |
| Kong | 8000 | API Gateway | Rate limiting, roteamento, validação JWT |
| Realtime | 4000 | WebSocket | Autorização de canais, limites de conexão |
| Storage | 5000 | Object Storage | Políticas de buckets, limites de tamanho de arquivo |
| Studio | 3000 | Painel administrativo | Acesso apenas interno (SSH Tunnel/VPN) |
Arquitetura alvo
Um setup estável separa pelo menos duas áreas de responsabilidade.
Internet
|
| HTTPS (443)
|
Reverse Proxy (Caddy / Nginx / Traefik)
|
| TLS terminado
|
Kong API Gateway
|
+-- GoTrue (Auth)
+-- PostgREST (API)
+-- Realtime (WebSocket)
+-- Storage (Object Store compatível com S3)
|
PostgreSQL
|
+-- RLS Policies
Paralelamente, roda um segundo sistema separado:
Audit Server
|
+-- Security Checks (Lynis, Trivy, Port Scans)
+-- Drift Detection (Config Diffs contra Baseline)
+-- Claude Code Review (análise contextual)
+-- Monitoring (métricas, alertas)
+-- Backup Verification (testes de restore)
Por que essa separação é necessária: se o sistema de produção e o sistema de auditoria são idênticos, um servidor comprometido pode simultaneamente manipular seus próprios checks de segurança.
Parte A - Decisões de infraestrutura
Estas decisões são tomadas uma única vez e formam a base para tudo o que vem depois.
A1 - Separar a infraestrutura: dois servidores
Implementação
Operar no mínimo dois servidores, físicos ou como VMs separadas:
supabase-prod (Hetzner Cloud CX32 ou superior)
audit-runner (Hetzner Cloud CX22 é suficiente)
Específico da Hetzner: No console da Hetzner Cloud, em “Servers”, criar duas instâncias separadas, ambas no mesmo projeto e mesma localização (ex.: fsn1). Em outros provedores: duas VMs na mesma região/zona.
supabase-prod carrega o stack Supabase completo e o PostgreSQL. audit-runner carrega Security Checks, Monitoring, Drift Detection e análise com Claude Code.
Condição verificável
# Ambos os servidores devem ser hosts separados
ssh supabase-prod hostname
ssh audit-runner hostname
# Expectativa: hostnames e IPs diferentes
Cenário de falha
Se auditoria e produção rodam no mesmo servidor e um invasor obtém acesso root, ele pode apagar logs, manipular resultados dos security checks e suprimir alertas. O comprometimento permanece indetectável.
A2 - Configurar rede privada
Implementação
Ambos os servidores se comunicam internamente por uma rede privada. Os serviços do Supabase são acessíveis apenas por esses IPs internos.
Específico da Hetzner: Em “Networks”, criar um vSwitch ou uma Cloud Network com sub-rede
10.0.1.0/24. Atribuir ambos os servidores à rede. A Hetzner cria automaticamente uma interface (tipicamenteens10). No Brasil: Locaweb Cloud VPC, Magalu Cloud VPC. Na UE: OVH vRack, Scaleway Private Networks.
# Em ambos os servidores, configurar a interface privada
# /etc/network/interfaces.d/60-private.cfg (específico da Hetzner)
auto ens10
iface ens10 inet static
address 10.0.1.10/24 # supabase-prod
# address 10.0.1.11/24 # audit-runner
# Ativar a rede
systemctl restart networking
# Verificar
ip addr show ens10
ping 10.0.1.11 # a partir do servidor prod
Condição verificável
# A partir do audit-runner: interface interna ativa?
ssh audit-runner "ip addr show ens10 | grep 10.0.1.11"
# PostgreSQL deve escutar apenas internamente
ssh supabase-prod "ss -tlnp | grep 5432"
# Expectativa: 10.0.1.10:5432, NÃO 0.0.0.0:5432
Cenário de falha
Se o PostgreSQL escuta em 0.0.0.0:5432 e o firewall tem um erro, o banco de dados fica diretamente acessível pela internet. Com a service_role key ou uma senha fraca do Postgres, o banco de dados inteiro fica comprometido.
A3 - Reverse Proxy com TLS
Implementação
Na frente do Kong fica um Reverse Proxy que termina o TLS e é o único serviço acessível externamente. Caddy é utilizado como exemplo porque traz Let’s Encrypt automático.
# 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
}
}
Alternativamente com 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;
# Suporte a WebSocket para Realtime
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Condição verificável
# TLS ativo e configurado corretamente?
curl -I https://app.example.com
# Expectativa: HTTP/2 200, header Strict-Transport-Security presente
# Verificar versão do TLS
openssl s_client -connect app.example.com:443 -tls1_2 </dev/null 2>&1 | grep "Protocol"
# Data de expiração do certificado
echo | openssl s_client -connect app.example.com:443 2>/dev/null | \
openssl x509 -noout -enddate
# Expectativa: notAfter pelo menos 14 dias no futuro
# Security headers presentes?
curl -sI https://app.example.com | grep -E \
"X-Frame-Options|Strict-Transport-Security|X-Content-Type-Options"
Cenário de falha
Sem TLS, os Auth-Tokens trafegam em texto plano pela rede. Qualquer pessoa no mesmo segmento de rede pode interceptá-los (Man-in-the-Middle). Sem renovação automática de certificados, o certificado expira após 90 dias e a aplicação fica inacessível.
A4 - Firewall em duas camadas
Implementação
Duas camadas que se protegem mutuamente:
Camada 1: Cloud Firewall (na frente do servidor)
Específico da Hetzner: Em “Firewalls”, criar um novo firewall e atribuí-lo a ambos os servidores. No Brasil: Locaweb Firewall, Magalu Cloud Firewall. Na UE: OVH Firewall, Scaleway Security Groups.
# Regras do Hetzner Cloud Firewall para supabase-prod
Inbound:
TCP 443 de 0.0.0.0/0 (HTTPS)
TCP 22 de ADMIN_IP/32 (SSH apenas do admin)
TCP ALL de 10.0.1.0/24 (rede interna)
Outbound:
ALL para 0.0.0.0/0 (Updates, DNS, Let's Encrypt)
Camada 2: Host Firewall (no servidor)
# /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 apenas do IP do admin
-A INPUT -p tcp --dport 22 -s ADMIN_IP -j ACCEPT
# HTTPS
-A INPUT -p tcp --dport 443 -j ACCEPT
# Rede interna (todas as portas)
-A INPUT -s 10.0.1.0/24 -j ACCEPT
# Descartar todo o resto (Default Policy)
COMMIT
# Ativar o firewall
apt install iptables-persistent
iptables-restore < /etc/iptables/rules.v4
# Salvar baseline para Drift Detection
iptables-save > /root/firewall-baseline.txt
Condição verificável
# De fora: apenas 443 aberta
nmap -p 22,80,443,5432,8000,9000 app.example.com
# Expectativa: apenas 443 open (22 apenas do IP do admin)
# No servidor: regras ativas?
iptables -L -n | grep -c "DROP"
# Expectativa: pelo menos 1 (Default DROP Policy)
# Drift Detection: o firewall mudou?
iptables-save | diff /root/firewall-baseline.txt -
# Expectativa: nenhuma diferença
Cenário de falha
Um único firewall pode ser configurado incorretamente. Um iptables -F (flush) no servidor abre todas as portas se não existir um Cloud Firewall. Por outro lado, um Cloud Firewall não protege contra processos que abrem novas portas no próprio servidor, se nenhum Host Firewall estiver ativo.
A5 - Proteger o acesso SSH
Implementação
# /etc/ssh/sshd_config
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
AllowUsers deploy
# Depositar SSH Key no servidor
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
# Recarregar SSHD
systemctl reload sshd
Condição verificável
# Login por senha deve falhar
ssh -o PasswordAuthentication=yes -o PubkeyAuthentication=no deploy@app.example.com
# Expectativa: Permission denied
# Login como root deve falhar
ssh root@app.example.com
# Expectativa: Permission denied
# Verificar configuração
sshd -T | grep -E "passwordauthentication|permitrootlogin"
# Expectativa: passwordauthentication no, permitrootlogin no
Cenário de falha
Acessos SSH baseados em senha são continuamente atacados pela internet (Brute Force). Uma senha fraca é tipicamente descoberta em poucas horas. Login como root significa que um invasor obtém imediatamente controle total sobre o servidor.
Parte B - Configurar e proteger os serviços do Supabase
Um stack self-hosted do Supabase é composto por mais de 10 serviços. Cada um possui suas próprias variáveis de ambiente, suas próprias roles de banco de dados e seus próprios requisitos de segurança. O docker-compose.yml oficial do Supabase tem mais de 400 linhas e contém muitas configurações que não parecem relevantes para segurança, mas que são.
Esta seção explica cada serviço, seu papel, sua configuração relevante para segurança e os erros típicos no setup.
Visão geral dos serviços
Internet
│
│ HTTPS (443)
│
Reverse Proxy (Caddy/Nginx) ← Parte A3
│
Kong (API Gateway) ← roteia para todos os serviços
│
├── GoTrue (Auth) ← /auth/v1/*
├── PostgREST (REST API) ← /rest/v1/*
├── Realtime ← /realtime/v1/*
├── Storage API ← /storage/v1/*
├── Edge Functions ← /functions/v1/*
├── Studio (Dashboard) ← / (protegido com Basic Auth)
│
├── Meta (Postgres-Meta) ← interno, para Studio
├── ImgProxy ← interno, para Storage
├── Analytics (Logflare) ← interno, para Logs
├── Vector ← interno, Log-Pipeline
└── Supavisor (Pooler) ← Connection Pooling
│
PostgreSQL ← Banco de dados com Init-Scripts
│
└── Roles: anon, authenticated, service_role,
authenticator, supabase_admin, supabase_auth_admin,
supabase_storage_admin
B0 - Gerar secrets (antes da primeira inicialização)
Isto deve acontecer ANTES da inicialização de todos os serviços. O .env.example oficial contém placeholders. Estes devem ser substituídos por valores gerados.
# JWT Secret (compartilhado por GoTrue, PostgREST, Realtime, Kong)
JWT_SECRET=$(openssl rand -base64 32)
# Senha do Postgres
POSTGRES_PASSWORD=$(openssl rand -base64 24)
# Senha do Dashboard (Basic Auth via Kong)
DASHBOARD_PASSWORD=$(openssl rand -base64 16)
# Logflare Tokens
LOGFLARE_PUBLIC_ACCESS_TOKEN=$(openssl rand -hex 16)
LOGFLARE_PRIVATE_ACCESS_TOKEN=$(openssl rand -hex 16)
# Senha root do MinIO (caso S3 Storage seja utilizado)
MINIO_ROOT_PASSWORD=$(openssl rand -hex 16)
# Realtime Secret Key Base (mín. 64 caracteres)
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 e Service Role Key (JWTs, gerados com o JWT_SECRET)
# Use https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys
# ou gere manualmente com jwt.io e o JWT_SECRET
Regra de segurança: Todos os secrets ficam no arquivo .env no servidor (permissões 600, não no Git). O Supabase utiliza um único JWT_SECRET para todos os serviços. Se este secret for comprometido, GoTrue, PostgREST e Realtime são afetados simultaneamente.
Condição verificável
# Todos os secrets necessários estão definidos?
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 "CRITICO: $var não está definido ou tem valor padrão"
fi
done
# Nenhuma senha idêntica?
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 "AVISO: Alguns secrets possuem valores idênticos"
fi
Cenário de falha
Secrets padrão do .env.example são publicamente conhecidos. Um invasor pode gerar tokens válidos com o JWT_SECRET padrão e obtém acesso total à API. Com a SERVICE_ROLE_KEY padrão, ele ainda contorna todas as RLS-Policies.
B1 - PostgreSQL: banco de dados e roles
O que este serviço faz
O PostgreSQL é o banco de dados central. No contexto do Supabase, ele possui um papel especial: armazena não apenas dados de aplicação, mas também dados de autenticação (GoTrue), configuração do Realtime, metadados do Storage e logs de Analytics. Os Init-Scripts criam schemas e roles especiais.
Configuração relevante para segurança
db:
image: supabase/postgres:15.8.1.085 # Imagem própria do Supabase, fixar versão
restart: unless-stopped
ports:
- "10.0.1.10:5432:5432" # APENAS interface interna
volumes:
# Init-Scripts (criam Schemas, Roles, Extensions)
- ./volumes/db/realtime.sql:/docker-entrypoint-initdb.d/migrations/99-realtime.sql:Z
- ./volumes/db/webhooks.sql:/docker-entrypoint-initdb.d/init-scripts/98-webhooks.sql:Z
- ./volumes/db/roles.sql:/docker-entrypoint-initdb.d/init-scripts/99-roles.sql:Z
- ./volumes/db/jwt.sql:/docker-entrypoint-initdb.d/init-scripts/99-jwt.sql:Z
- ./volumes/db/_supabase.sql:/docker-entrypoint-initdb.d/migrations/97-_supabase.sql:Z
- ./volumes/db/logs.sql:/docker-entrypoint-initdb.d/migrations/99-logs.sql:Z
- ./volumes/db/pooler.sql:/docker-entrypoint-initdb.d/migrations/99-pooler.sql:Z
# Dados persistentes
- ./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}
Os Init-Scripts criam as seguintes roles:
postgres → Superuser (apenas para admin, nunca para serviços)
anon → Requests não autenticados (via PostgREST)
authenticated → Requests autenticados (via PostgREST)
service_role → Contorna RLS (para operações administrativas)
authenticator → PostgREST usa esta role para conectar
supabase_admin → Role interna de admin (Realtime, Analytics)
supabase_auth_admin → Específica do GoTrue (schema auth)
supabase_storage_admin → Específica do Storage (schema storage)
O que pode dar errado
O roles.sql define os grants para cada role. Se este arquivo for modificado (ex.: para resolver rapidamente um problema), as roles podem receber permissões excessivas. A role anon deve ter apenas as permissões que as RLS-Policies explicitamente permitem.
Condição verificável
# Verificar roles e suas permissões
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 NÃO pode ser superuser
docker compose exec -T db psql -U postgres -c \
"SELECT rolname, rolsuper FROM pg_roles WHERE rolname = 'anon' AND rolsuper = true;"
# Expectativa: nenhuma linha
# Postgres escuta APENAS internamente
ss -tlnp | grep 5432
# Expectativa: 10.0.1.10:5432, NÃO 0.0.0.0:5432
B2 - Kong: API Gateway e roteamento
O que este serviço faz
Kong é o ponto de entrada central para todos os requests de API. Ele roteia com base no caminho da URL para os serviços corretos, lida com a validação de JWT e protege o dashboard do Studio com Basic Auth.
Configuração relevante para segurança
kong:
image: kong:2.8.1 # Fixar versão
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000" # APENAS localhost (Reverse Proxy na frente)
- "127.0.0.1:8443:8443" # HTTPS interno
volumes:
- ./volumes/api/kong.yml:/home/kong/temp.yml:ro,z
environment:
KONG_DATABASE: "off" # Config declarativa, sem DB
KONG_DECLARATIVE_CONFIG: /home/kong/kong.yml
KONG_DNS_ORDER: LAST,A,CNAME
KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth,request-termination,ip-restriction
SUPABASE_ANON_KEY: ${ANON_KEY}
SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY}
DASHBOARD_USERNAME: ${DASHBOARD_USERNAME}
DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD}
entrypoint: bash -c 'eval "echo \"$$(cat ~/temp.yml)\"" > ~/kong.yml && /docker-entrypoint.sh kong docker-start'
O kong.yml define o roteamento:
/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 (opcional)
/ → Studio (port 3000) + Basic Auth
O que pode dar errado
Kong em 0.0.0.0:8000 em vez de 127.0.0.1:8000 significa que a API fica diretamente acessível sem Reverse Proxy (sem TLS). Uma senha fraca do dashboard dá acesso via Basic Auth ao Studio e, consequentemente, ao banco de dados inteiro. O kong.yml pode ser configurado de forma que a validação de JWT seja desativada para certas rotas.
Condição verificável
# Kong apenas no localhost?
ss -tlnp | grep 8000
# Expectativa: 127.0.0.1:8000
# Senha do dashboard forte o suficiente?
DASH_PW_LEN=$(grep "DASHBOARD_PASSWORD" .env | cut -d= -f2 | wc -c)
[ "$DASH_PW_LEN" -lt 16 ] && echo "AVISO: Senha do dashboard muito curta"
# kong.yml: JWT Validation ativa em todas as rotas de API?
grep -A5 "key-auth" volumes/api/kong.yml | head -20
B3 - GoTrue: autenticação
O que este serviço faz
GoTrue lida com registro de usuários, login, Magic Links, OAuth, MFA e emissão de tokens. É o único serviço que emite JWTs e possui um DB-User próprio (supabase_auth_admin) com acesso ao schema auth.
Configuração relevante para segurança
auth:
image: supabase/gotrue:v2.184.0 # Fixar versão
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
# Banco de dados (Admin-User próprio)
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} # MÁXIMO 3600 (1 hora)
GOTRUE_JWT_AUD: authenticated
GOTRUE_JWT_ADMIN_ROLES: service_role
GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated
# Registro
GOTRUE_DISABLE_SIGNUP: ${DISABLE_SIGNUP} # true se app fechada
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_MAILER_AUTOCONFIRM: false # SEMPRE false em produção
# SMTP (para Magic Links, confirmação por 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}
# Caminhos de URL do Mailer (devem corresponder ao roteamento do 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 após Auth)
GOTRUE_SITE_URL: ${SITE_URL} # URL da sua app Next.js
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
# Refresh Token Rotation (impede reuso de token)
GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: true
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10
Configuração SMTP: por que é relevante para segurança
Sem um servidor SMTP funcional, nenhum e-mail de confirmação pode ser enviado. Se GOTRUE_MAILER_AUTOCONFIRM: true for definido para contornar isso, qualquer pessoa pode se registrar com um endereço de e-mail arbitrário. Isso significa: nenhuma verificação de identidade.
Condição verificável
# GoTrue Health Check
docker compose exec -T auth wget --no-verbose --tries=1 --spider http://localhost:9999/health
# JWT Expiry não superior a 3600?
grep "JWT_EXPIRY\|JWT_EXP" .env | head -1
# Expectativa: 3600 ou menos
# Autoconfirm desativado?
grep "AUTOCONFIRM" .env
# Expectativa: false
# SMTP configurado (não vazio)?
for var in SMTP_HOST SMTP_PORT SMTP_USER SMTP_PASS; do
VAL=$(grep "^${var}=" .env | cut -d= -f2)
[ -z "$VAL" ] && echo "AVISO: $var está vazio"
done
# Refresh Token Rotation ativo?
grep "REFRESH_TOKEN_ROTATION" .env docker-compose.yml 2>/dev/null
# Expectativa: true
Cenário de falha
Com AUTOCONFIRM=true e DISABLE_SIGNUP=false, qualquer pessoa pode criar uma conta e usá-la imediatamente, sem confirmar o endereço de e-mail. Um invasor pode se registrar com endereços arbitrários e obtém imediatamente a role authenticated no banco de dados. Sem Refresh Token Rotation, um refresh token roubado pode ser usado indefinidamente.
B4 - PostgREST: REST API
O que este serviço faz
PostgREST gera automaticamente uma REST API a partir do schema do PostgreSQL. É o principal ponto de acesso para dados. A segurança está primariamente nas roles do PostgreSQL e nas RLS-Policies, não no PostgREST em si.
Configuração relevante para segurança
rest:
image: postgrest/postgrest:v14.1 # Fixar versão
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}
Como o PostgREST trabalha com as roles:
Request sem JWT → PostgREST usa role "anon"
Request com JWT → PostgREST muda para role do JWT (ex.: "authenticated")
Request com service_role JWT → PostgREST usa "service_role" (contorna RLS)
O user authenticator conecta ao banco e muda via SET ROLE para a role correspondente. Isso significa: os grants nas roles anon e authenticated são a verdadeira camada de segurança.
Condição verificável
# PostgREST usa a role authenticator (não postgres)?
grep "PGRST_DB_URI" docker-compose.yml | grep "authenticator"
# Expectativa: Sim
# Schemas explicitamente definidos (não todos)?
grep "PGRST_DB_SCHEMAS" .env
# Expectativa: public,storage,graphql_public (não vazio = todos os schemas)
Cenário de falha
Se PGRST_DB_URI usa o superuser postgres em vez de authenticator, cada request tem direitos de superuser e RLS é ineficaz. Se PGRST_DB_SCHEMAS está vazio, PostgREST expõe todos os schemas, incluindo schemas internos do Supabase (auth, _realtime, _analytics).
B5 - Realtime: subscrições WebSocket
O que este serviço faz
Realtime permite subscrições baseadas em WebSocket para alterações no banco de dados. Utiliza o user supabase_admin e o schema _realtime.
Configuração relevante para segurança
realtime:
container_name: realtime-dev.supabase-realtime # Nome é relevante para 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 # ALTERAR em produção
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${SECRET_KEY_BASE} # mín. 64 caracteres
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
O que pode dar errado
DB_ENC_KEY: supabaserealtime é um valor padrão. Em produção, deve ser alterado. SECRET_KEY_BASE deve ter no mínimo 64 caracteres, caso contrário o serviço não inicia ou fica inseguro. Realtime usa supabase_admin, o que significa que internamente tem acesso total ao banco. A segurança está na validação de JWT: apenas usuários autenticados podem abrir subscrições, e RLS determina quais rows eles veem.
Condição verificável
# DB_ENC_KEY não está no valor padrão?
grep "DB_ENC_KEY" docker-compose.yml
# NÃO "supabaserealtime"
# SECRET_KEY_BASE longo o suficiente?
SKB_LEN=$(grep "SECRET_KEY_BASE" .env | cut -d= -f2 | wc -c)
[ "$SKB_LEN" -lt 64 ] && echo "CRITICO: SECRET_KEY_BASE muito curto ($SKB_LEN caracteres)"
# 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 e MinIO (S3)
O que este serviço faz
Storage gerencia uploads e downloads de arquivos. Por padrão, armazena arquivos localmente no volume. Para produção, recomenda-se um backend compatível com S3 (MinIO self-hosted ou um serviço S3 sem Cloud Act).
Storage local (padrão)
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, ajustar conforme necessidade
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
MinIO para backend S3 (produção)
Se você usar MinIO, um serviço adicional é necessário:
# docker-compose.s3.yml (adicional ao Compose principal)
minio:
image: minio/minio:latest # Fixar versão em produção
restart: unless-stopped
ports:
- "127.0.0.1:9000:9000" # API, APENAS localhost
- "127.0.0.1:9001:9001" # Console, APENAS localhost
volumes:
- minio-data:/data
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD} # mín. 8 caracteres
command: server /data --console-address ":9001"
Em seguida, na configuração do 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
Condição verificável
# Storage Health Check
docker compose exec -T storage wget --no-verbose --tries=1 --spider http://localhost:5000/status
# MinIO não acessível externamente (se utilizado)?
ss -tlnp | grep 9000
# Expectativa: 127.0.0.1:9000 (não 0.0.0.0)
# MinIO com credenciais padrão?
grep "MINIO_ROOT" .env | grep -iE "minioadmin\|minio123\|admin"
# Expectativa: nenhum resultado
# Volume do Storage existe e contém dados?
ls -la volumes/storage/ 2>/dev/null | head -5
# Storage Bucket Policies (via MinIO Client)
# mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
# mc admin policy ls local
Cenário de falha
MinIO com credenciais padrão (minioadmin:minioadmin) em 0.0.0.0:9000 significa: qualquer pessoa na internet pode ler e escrever todos os arquivos. A Console do MinIO na porta 9001 fornece adicionalmente uma Web-UI para administração completa. Storage Bucket Policies podem estar definidas como “public”, o que significa que arquivos ficam acessíveis sem autenticação.
B7 - Analytics, Vector e Supavisor (serviços internos)
O que estes serviços fazem
Estes serviços não são diretamente acessíveis de fora, mas são relevantes para segurança porque possuem acesso privilegiado ao banco de dados.
Analytics (Logflare): Coleta e armazena logs de todos os serviços. Utiliza supabase_admin e o schema _analytics.
Vector: Pipeline de logs que encaminha logs do Docker para o Logflare. Possui acesso ao Docker Socket.
Supavisor: Connection Pooler para PostgreSQL. Gerencia o pool de conexões e possui acesso com supabase_admin.
Configuração relevante para segurança
analytics:
image: supabase/logflare:1.27.0
ports:
- "127.0.0.1:4000:4000" # APENAS 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 expõe porta do 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}
O que pode dar errado
Vector com acesso ao Docker Socket (/var/run/docker.sock) pode ler logs de containers. O socket deve ser montado Read-Only (:ro). Analytics na porta 4000 com tokens padrão expõe logs de todos os serviços. Supavisor em 0.0.0.0:5432 em vez da interface interna torna o Connection Pooler (e consequentemente o PostgreSQL) acessível de fora.
Condição verificável
# Analytics apenas interno?
ss -tlnp | grep 4000
# Expectativa: 127.0.0.1:4000
# Vector Docker Socket Read-Only?
grep "docker.sock" docker-compose.yml | grep ":ro"
# Expectativa: :ro presente
# Supavisor porta apenas interna?
ss -tlnp | grep 5432
# Expectativa: 10.0.1.10:5432 (interface interna)
# Logflare Tokens não estão no padrão?
grep "LOGFLARE.*TOKEN" .env | grep -iE "your-\|change\|example"
# Expectativa: nenhum resultado
B8 - Studio: proteger o dashboard
O que este serviço faz
Studio é a Web-UI para gerenciamento de banco de dados, administração de usuários e consultas SQL. Através da SERVICE_ROLE_KEY e acesso direto ao Postgres, ele tem acesso total a tudo.
Configuração relevante para segurança
Studio é protegido via Kong com Basic Auth (DASHBOARD_USERNAME/DASHBOARD_PASSWORD). Adicionalmente, Studio em produção deve ou não estar rodando, ou ser acessível apenas via SSH Tunnel.
Opção 1: Não iniciar Studio em produção (recomendado)
# Em docker-compose.override.yml ou remover Studio do Compose
# Usar Studio apenas localmente com supabase start para desenvolvimento
Opção 2: Studio apenas via SSH Tunnel
# A partir da workstation:
ssh -L 3000:localhost:3000 deploy@supabase-prod
# Depois no navegador: http://localhost:3000
Condição verificável
# Container do Studio está rodando? (Não deveria em produção)
docker compose ps | grep studio
# Recomendação: não running em produção
# Se Studio está rodando: não acessível externamente?
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 \
"https://app.example.com:3000" 2>/dev/null
# Expectativa: Timeout ou Connection Refused
# Senha do dashboard forte?
DASH_LEN=$(grep "DASHBOARD_PASSWORD" .env | cut -d= -f2 | wc -c)
[ "$DASH_LEN" -lt 16 ] && echo "AVISO: Senha do dashboard muito curta"
Cenário de falha
Studio com senha fraca do dashboard e acesso público dá ao invasor uma UI completa de administração de banco de dados. Ele pode executar queries SQL, deletar usuários, desativar RLS e exportar dados. Este é o pior cenário para um stack self-hosted.
Checklist de serviços
Após o setup de todos os serviços, verificar antes do primeiro uso em produção:
Secrets
[ ] Todos os secrets gerados (nenhum valor padrão)
[ ] JWT_SECRET no mínimo 32 caracteres
[ ] POSTGRES_PASSWORD no mínimo 24 caracteres
[ ] DASHBOARD_PASSWORD no mínimo 16 caracteres
[ ] SECRET_KEY_BASE no mínimo 64 caracteres
[ ] MINIO_ROOT_PASSWORD no mínimo 8 caracteres (se MinIO)
[ ] DB_ENC_KEY alterado (não "supabaserealtime")
PostgreSQL
[ ] Porta apenas na interface interna (10.0.1.10:5432)
[ ] Init-Scripts inalterados (roles.sql, jwt.sql etc.)
[ ] Roles corretamente criadas (anon não é superuser)
Kong
[ ] Porta apenas no localhost (127.0.0.1:8000)
[ ] JWT-Validation ativa em todas as rotas de API
[ ] Senha do dashboard forte
GoTrue (Auth)
[ ] JWT_EXP no máximo 3600
[ ] AUTOCONFIRM false
[ ] SMTP configurado e testado
[ ] SITE_URL e API_EXTERNAL_URL corretos
[ ] Refresh Token Rotation ativo
[ ] DISABLE_SIGNUP definido se app fechada
PostgREST
[ ] Usa role authenticator (não postgres)
[ ] DB_SCHEMAS explicitamente definidos
Realtime
[ ] DB_ENC_KEY alterado
[ ] SECRET_KEY_BASE no mínimo 64 caracteres
Storage / MinIO
[ ] MinIO não acessível externamente
[ ] Credenciais padrão do MinIO alteradas
[ ] Permissões do volume do Storage corretas
Serviços internos
[ ] Porta do Analytics apenas localhost
[ ] Vector Docker Socket Read-Only
[ ] Porta do Supavisor apenas interna
[ ] Logflare Tokens não estão no padrão
Studio
[ ] Em produção desativado OU apenas via SSH Tunnel
[ ] Senha do dashboard forte
Parte C - Configurar o stack Supabase
Estes passos dizem respeito à instalação do Supabase propriamente dita e às suas configurações relevantes de segurança.
C1 - Versionar o deployment do Supabase
Implementação
Todos os arquivos de infraestrutura pertencem a um repositório Git. Deployments acontecem exclusivamente por esse repositório, nunca por alterações manuais no servidor.
infra/
docker-compose.yml
.env.example (template, sem secrets reais)
caddy/
Caddyfile
postgres/
migrations/
scripts/
backup.sh
restore.sh
health-check.sh
security-check.sh
runbooks/
supabase-production.md
security-baseline.md
Workflow de deployment
# No servidor
cd /opt/supabase
git pull origin main
# Carregar variáveis de ambiente (arquivo existe apenas no servidor)
source .env
# Iniciar/atualizar o stack
docker compose up -d
# Health Check
./scripts/health-check.sh
Condição verificável
# Existem uncommitted changes no servidor?
cd /opt/supabase && git status --porcelain
# Expectativa: vazio (nenhuma alteração local)
# O servidor está na versão atual?
git log --oneline -1
# Comparar com Remote
git fetch origin && git diff HEAD origin/main --stat
# Expectativa: nenhuma diferença
Cenário de falha
Alterações manuais no docker-compose.yml no servidor são sobrescritas no próximo git pull ou geram conflitos de merge. Pior ainda: ninguém sabe qual alteração foi feita quando e por quem. Após a perda de um servidor, a configuração não é reproduzível.
C2 - docker-compose.yml: stack mínimo de produção
Implementação
O Supabase fornece um arquivo Compose de referência com mais de 400 linhas e cerca de 15 serviços. Nem todos são necessários para produção. Aqui estão as decisões relevantes de segurança:
Stack mínimo (estes serviços são necessários):
postgres Banco de dados
kong API Gateway
gotrue Auth
postgrest REST API
realtime WebSocket (se necessário)
storage File Storage (se necessário)
meta Metadados para PostgREST
Não em produção (omitir ou apenas internamente):
studio Admin UI, apenas via SSH Tunnel ou VPN
imgproxy apenas se transformações de imagem forem necessárias
inbucket apenas para testes locais de e-mail
Trecho da configuração relevante de segurança:
# docker-compose.yml (trecho, partes relevantes de segurança)
services:
postgres:
image: supabase/postgres:15.6.1.143 # Fixar versão
restart: unless-stopped
ports:
- "10.0.1.10:5432:5432" # APENAS interface interna
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 # Fixar versão
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000" # APENAS localhost (Reverse Proxy na frente)
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 # Fixar versão
restart: unless-stopped
environment:
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_EXP: 3600 # 1 hora, não mais
GOTRUE_EXTERNAL_EMAIL_ENABLED: true
GOTRUE_MAILER_AUTOCONFIRM: false # Forçar confirmação por e-mail
GOTRUE_DISABLE_SIGNUP: false # definir como true se registro estiver fechado
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 # Fixar versão
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
Pontos críticos de configuração:
GOTRUE_JWT_EXP: 3600 não superior a 3600 (1h)
GOTRUE_MAILER_AUTOCONFIRM false em produção
GOTRUE_DISABLE_SIGNUP true se não houver registro aberto
REFRESH_TOKEN_ROTATION true (impede reuso de token)
Versões das imagens sempre fixar, nunca :latest
Porta do Postgres vincular apenas na interface interna
Porta do Kong apenas no localhost (Reverse Proxy na frente)
Condição verificável
# Imagens fixadas (nenhum :latest)?
grep "image:" docker-compose.yml | grep -c "latest"
# Expectativa: 0
# Postgres acessível apenas internamente?
docker compose exec postgres ss -tlnp | grep 5432
# Expectativa: apenas 10.0.1.10:5432 ou 0.0.0.0:5432 (então verificar firewall)
# Postgres não acessível externamente?
nmap -p 5432 app.example.com
# Expectativa: filtered ou closed
# JWT Expiry correto?
grep "GOTRUE_JWT_EXP" .env
# Expectativa: 3600 ou menos
# Supabase Studio não acessível externamente?
curl -s -o /dev/null -w "%{http_code}" https://app.example.com:3000
# Expectativa: Timeout ou Connection Refused
Cenário de falha
Imagens sem versão fixada (image: supabase/gotrue:latest) podem, ao executar um docker compose pull, introduzir silenciosamente uma nova versão que contém Breaking Changes ou uma vulnerabilidade conhecida. Se o Postgres escuta em 0.0.0.0:5432 e o firewall falha temporariamente, o banco de dados inteiro fica acessível na internet. Se GOTRUE_JWT_EXP está definido como 86400 (24h), um token roubado é válido por um dia inteiro.
C3 - Gerenciar secrets de forma completa e segura
Implementação
Um stack Supabase possui no mínimo estes secrets:
# .env (apenas no servidor, nunca no Git)
# Secrets principais
JWT_SECRET= # mín. 32 caracteres, gerado com openssl rand -base64 32
ANON_KEY= # JWT Token com role anon
SERVICE_ROLE_KEY= # JWT Token com service_role, bypassa RLS
POSTGRES_PASSWORD= # mín. 24 caracteres, gerado
# Dashboard
DASHBOARD_USERNAME= # Login do Supabase Studio
DASHBOARD_PASSWORD= # mín. 16 caracteres
# E-Mail (GoTrue)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
SMTP_SENDER_NAME=
# Storage (se backend S3)
S3_ACCESS_KEY=
S3_SECRET_KEY=
Gerar secrets:
# JWT Secret
openssl rand -base64 32
# Senha do Postgres
openssl rand -base64 24
# Gerar Anon e Service Role Keys (Supabase CLI)
# Ou criar JWT manualmente com o JWT_SECRET
Gerenciamento de secrets no servidor:
# Arquivo .env com permissões restritivas
chmod 600 /opt/supabase/.env
chown deploy:deploy /opt/supabase/.env
# Verificar que .env não está no Git
cat /opt/supabase/.gitignore | grep ".env"
No repositório fica apenas o template:
# .env.example (no 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=
O mesmo princípio se aplica a toda segurança de dados na infraestrutura de IA corporativa.
Condição verificável
# .env não está no Git?
cd /opt/supabase && git ls-files .env
# Expectativa: vazio
# .env está no .gitignore?
grep "^\.env$" .gitignore
# Expectativa: .env
# Permissões do arquivo corretas?
stat -c "%a %U" .env
# Expectativa: 600 deploy
# Secrets com comprimento suficiente?
awk -F= '{if (length($2) < 16 && $2 != "" && $1 !~ /PORT|HOST|NAME/) print "MUITO CURTO: "$1}' .env
# Expectativa: nenhuma saída
# Nenhuma senha padrão?
grep -iE "password|secret" .env | grep -iE "change.me|default|example|your.*here"
# Expectativa: nenhum resultado
Cenário de falha
O problema de segurança mais comum no Supabase Self-Hosting não é um exploit de servidor, mas sim um secret vazado. Se o .env for commitado no Git e o repositório for público (ou se tornar público), todos os secrets ficam expostos. Com a SERVICE_ROLE_KEY, é possível ler e escrever no banco de dados inteiro sem RLS.
C4 - Verificar políticas de banco de dados
Implementação
No Supabase, grande parte da segurança está no PostgreSQL Row Level Security (RLS), não no servidor de aplicação. Cada tabela no schema public deve ter RLS ativado.
Encontrar tabelas sem RLS:
-- Todas as tabelas public sem RLS
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
AND rowsecurity = false;
Encontrar tabelas com RLS mas sem policies:
-- RLS ativo mas nenhuma policy definida = nenhum acesso possível
-- (pode ser intencional, mas verificar)
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;
Encontrar policies excessivamente abertas:
-- Policies que concedem acesso total a todas as roles
SELECT tablename, policyname, permissive, roles, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
AND (roles = '{public}' OR qual = 'true');
Verificar uso do Service Role:
-- Quais roles existem e quais permissões possuem?
SELECT rolname, rolsuper, rolcreaterole, rolcreatedb
FROM pg_roles
WHERE rolname IN ('anon', 'authenticated', 'service_role', 'authenticator');
Condição verificável
# Como script automatizado a partir do 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;
\""
# Expectativa: nenhuma tabela (ou apenas exceções intencionais)
Cenário de falha
Uma tabela users com rowsecurity = false é totalmente legível via PostgREST API por qualquer pessoa com a anon key. Isso inclui todas as colunas, inclusive endereços de e-mail, números de telefone e outros dados pessoais. Um simples comando curl com a anon key pública é suficiente.
Parte D - Operação e monitoramento
Estes passos são executados regularmente e de forma automatizada.
D1 - Automatizar backups
Implementação
Dumps diários do PostgreSQL, criptografados e armazenados externamente.
#!/bin/bash
# scripts/backup.sh
set -euo pipefail
BACKUP_DIR="/opt/backups"
DATE=$(date +%Y-%m-%d_%H%M)
RETENTION_DAYS=30
GPG_RECIPIENT="backup@example.com" # GPG Key ID
# PostgreSQL Dump
docker compose exec -T postgres pg_dump \
-U postgres \
--format=custom \
--compress=9 \
postgres > "${BACKUP_DIR}/db_${DATE}.dump"
# Salvar Storage Buckets (se Supabase Storage for utilizado)
docker compose exec -T storage tar -czf - /var/lib/storage \
> "${BACKUP_DIR}/storage_${DATE}.tar.gz"
# Criptografar
for file in "${BACKUP_DIR}"/*_${DATE}.*; do
gpg --encrypt --recipient "${GPG_RECIPIENT}" "$file"
rm "$file" # Apagar versão não criptografada
done
# Copiar para servidor externo (audit-runner ou S3)
rsync -az "${BACKUP_DIR}/"*_${DATE}*.gpg \
deploy@10.0.1.11:/opt/backup-archive/
# Apagar backups antigos (localmente)
find "${BACKUP_DIR}" -name "*.gpg" -mtime +${RETENTION_DAYS} -delete
# No servidor de backup, também limpar
ssh deploy@10.0.1.11 \
"find /opt/backup-archive -name '*.gpg' -mtime +${RETENTION_DAYS} -delete"
echo "Backup ${DATE} concluído"
# Configurar Cron Job
# crontab -e
0 3 * * * /opt/supabase/scripts/backup.sh >> /var/log/backup.log 2>&1
Condição verificável
# Backup de hoje existe?
ls -la /opt/backups/*_$(date +%Y-%m-%d)*.gpg
# Backup chegou no servidor externo?
ssh deploy@10.0.1.11 "ls -la /opt/backup-archive/*_$(date +%Y-%m-%d)*.gpg"
# Tamanho do backup plausível (não 0 bytes)?
find /opt/backups -name "*.gpg" -size 0 -print
# Expectativa: nenhum resultado
Cenário de falha
Armazenar backups apenas no mesmo servidor significa: se o servidor falhar ou for criptografado (Ransomware), os backups também se perdem. Backups não criptografados em um servidor externo são um vazamento de dados, pois o dump contém todos os dados das tabelas em texto plano.
D2 - Testar o restore regularmente
Implementação
Uma vez por mês, restaurar um backup em um sistema de teste.
#!/bin/bash
# scripts/restore-test.sh
set -euo pipefail
BACKUP_FILE=$1 # ex.: /opt/backup-archive/db_2026-03-01_0300.dump.gpg
# Descriptografar
gpg --decrypt "$BACKUP_FILE" > /tmp/restore-test.dump
# Iniciar container de teste
docker run -d --name restore-test \
-e POSTGRES_PASSWORD=testpassword \
supabase/postgres:15.6.1.143
sleep 10
# Restore
docker exec -i restore-test pg_restore \
-U postgres \
--dbname=postgres \
--clean \
--if-exists \
< /tmp/restore-test.dump
# Verificar tabelas
docker exec restore-test psql -U postgres -c \
"SELECT schemaname, tablename FROM pg_tables WHERE schemaname = 'public';"
# Verificar Row Counts
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;"
# Limpar
docker rm -f restore-test
rm /tmp/restore-test.dump
echo "Teste de restore concluído"
Condição verificável
# Executar script de teste de restore e verificar Exit Code
./scripts/restore-test.sh /opt/backup-archive/db_latest.dump.gpg
echo $?
# Expectativa: 0
# Verificar log do último teste de restore
cat /var/log/restore-test.log | tail -20
Cenário de falha
Muitas equipes possuem backups que rodam há meses, mas nunca testaram um restore. Problemas típicos: formato incorreto do pg_dump (Plain Text em vez de Custom), permissões ausentes no restore, versões incompatíveis do PostgreSQL entre backup e restore. Tudo isso só aparece quando o restore é realmente necessário.
D3 - Checks diários de infraestrutura
Implementação
Um script no audit-runner verifica diariamente o estado do sistema de produção.
#!/bin/bash
# scripts/security-check.sh (roda no audit-runner)
set -euo pipefail
PROD_HOST="10.0.1.10"
REPORT=""
CRITICAL=0
# 1. Status dos Containers
STOPPED=$(ssh deploy@${PROD_HOST} "docker compose ps --format json" | \
jq -r 'select(.State != "running") | .Name')
if [ -n "$STOPPED" ]; then
REPORT+="CRITICO: Containers não running: ${STOPPED}\n"
CRITICAL=1
fi
# 2. Portas abertas externamente
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+="CRITICO: Portas inesperadas abertas: ${OPEN_PORTS}\n"
CRITICAL=1
fi
# 3. Data de expiração do certificado
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+="AVISO: Certificado TLS expira em ${DAYS_LEFT} dias\n"
fi
# 4. Drift do Firewall
FIREWALL_DIFF=$(ssh deploy@${PROD_HOST} "iptables-save" | \
diff /opt/baselines/firewall-baseline.txt - || true)
if [ -n "$FIREWALL_DIFF" ]; then
REPORT+="AVISO: Firewall foi alterado:\n${FIREWALL_DIFF}\n"
fi
# 5. Versões das imagens Docker (Drift contra Baseline)
IMAGE_DIFF=$(ssh deploy@${PROD_HOST} \
"docker compose images --format '{{.Repository}}:{{.Tag}}'" | \
diff /opt/baselines/image-versions.txt - || true)
if [ -n "$IMAGE_DIFF" ]; then
REPORT+="AVISO: Versões dos containers foram alteradas:\n${IMAGE_DIFF}\n"
fi
# 6. Espaço em disco
DISK_USAGE=$(ssh deploy@${PROD_HOST} "df -h / | tail -1 | awk '{print \$5}' | tr -d '%'")
if [ "$DISK_USAGE" -gt 85 ]; then
REPORT+="AVISO: Uso de disco em ${DISK_USAGE}%\n"
fi
# 7. Status do Backup
LAST_BACKUP=$(ssh deploy@${PROD_HOST} "ls -t /opt/backups/*.gpg 2>/dev/null | head -1")
if [ -z "$LAST_BACKUP" ]; then
REPORT+="CRITICO: Nenhum backup encontrado\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+="AVISO: Último backup tem ${BACKUP_AGE} horas\n"
fi
fi
# 8. Verificação de 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+="AVISO: ${UNPROTECTED} tabelas sem RLS\n"
fi
# Resultado
echo "=== Security Check $(date) ==="
if [ -n "$REPORT" ]; then
echo -e "$REPORT"
else
echo "Todos os checks aprovados"
fi
# Em caso de findings críticos: enviar alerta
if [ "$CRITICAL" -eq 1 ]; then
echo -e "$REPORT" | mail -s "CRITICO: Security Check $(date)" ops@example.com
fi
# Cron no audit-runner
0 7 * * * /opt/audit/scripts/security-check.sh >> /var/log/security-check.log 2>&1
Condição verificável
# O script de check rodou hoje?
grep "$(date +%Y-%m-%d)" /var/log/security-check.log | tail -1
# Expectativa: entrada de hoje presente
# Resultado?
grep "Todos os checks aprovados\|CRITICO\|AVISO" /var/log/security-check.log | tail -5
D4 - Claude Code como camada de análise contextual
O Claude Code analisa os resultados dos checks determinísticos e reconhece correlações que scripts não conseguem identificar.
Arquitetura
Checks diários (D3)
|
+-- Findings determinísticos
| (portas abertas, drift de firewall, backups ausentes)
|
+---> Review semanal com Claude Code
|
+-- Arquivos de config (docker-compose.yml, Caddyfile, .env.example)
+-- Logs dos Security Checks dos últimos 7 dias
+-- Git Diff das alterações de infraestrutura
+-- Exportação de RLS Policies
|
+---> Relatório priorizado
|
+---> Decisão DevOps (humano)
Script concreto
#!/bin/bash
# scripts/claude-review-prep.sh (roda no 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'
# Input para Review Semanal de Segurança
## Contexto
Stack Supabase self-hosted na Hetzner Cloud.
Arquitetura: Reverse Proxy -> Kong -> Supabase Services -> PostgreSQL
Sistema de auditoria em servidor separado.
HEADER
# Logs dos Security Checks dos últimos 7 dias
echo -e "\n## Resultados dos Security Checks (últimos 7 dias)\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
grep -A 20 "Security Check" /var/log/security-check.log | \
tail -100 >> "$OUTPUT"
echo '```' >> "$OUTPUT"
# Configuração atual do Docker Compose (sem secrets)
echo -e "\n## docker-compose.yml atual\n" >> "$OUTPUT"
echo '```yaml' >> "$OUTPUT"
ssh deploy@10.0.1.10 "cat /opt/supabase/docker-compose.yml" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
# Git Diff da última semana
echo -e "\n## Alterações de infraestrutura (últimos 7 dias)\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 do RLS
echo -e "\n## Status do 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"
# Versões dos containers
echo -e "\n## Versões dos containers\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
ssh deploy@10.0.1.10 "docker compose images --format '{{.Repository}}:{{.Tag}}'" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
echo "Input para review criado: $OUTPUT"
O que o Claude Code não faz
Claude NÃO executa alterações automáticas em produção.
Claude NÃO faz deploy.
Claude NÃO rotaciona secrets.
Claude NÃO possui acesso direto ao servidor de produção.
Claude analisa dados que lhe são fornecidos
e cria relatórios para decisões humanas.
Checklist de deployment
Antes da primeira ativação e após alterações significativas:
Parte A - Infraestrutura
[ ] Dois servidores separados (prod + audit)
[ ] Rede privada configurada e testada
[ ] Cloud Firewall ativo (apenas 443, SSH do IP do admin)
[ ] Host Firewall ativo (iptables)
[ ] Baseline do firewall salva
[ ] SSH: apenas keys, sem root, sem senha
[ ] Reverse Proxy configurado (Caddy/Nginx)
[ ] TLS ativo com renovação automática
[ ] Security Headers definidos (HSTS, CSP, X-Frame-Options)
[ ] WebSocket Proxy para Realtime configurado
Parte B - Serviços do Supabase
Secrets (B0)
[ ] Todos os secrets gerados (nenhum valor padrão)
[ ] JWT_SECRET no mínimo 32 caracteres
[ ] POSTGRES_PASSWORD no mínimo 24 caracteres
[ ] DASHBOARD_PASSWORD no mínimo 16 caracteres
[ ] SECRET_KEY_BASE no mínimo 64 caracteres
[ ] MINIO_ROOT_PASSWORD no mínimo 8 caracteres (se MinIO)
[ ] DB_ENC_KEY alterado (não "supabaserealtime")
PostgreSQL (B1)
[ ] Porta apenas na interface interna (10.0.1.10:5432)
[ ] Init-Scripts inalterados (roles.sql, jwt.sql etc.)
[ ] Roles corretamente criadas (anon não é superuser)
Kong (B2)
[ ] Porta apenas no localhost (127.0.0.1:8000)
[ ] JWT-Validation ativa em todas as rotas de API
[ ] Senha do dashboard forte
GoTrue (B3)
[ ] JWT_EXP no máximo 3600
[ ] AUTOCONFIRM false
[ ] SMTP configurado e testado
[ ] SITE_URL e API_EXTERNAL_URL corretos
[ ] Refresh Token Rotation ativo
[ ] DISABLE_SIGNUP definido se app fechada
PostgREST (B4)
[ ] Usa role authenticator (não postgres)
[ ] DB_SCHEMAS explicitamente definidos
Realtime (B5)
[ ] DB_ENC_KEY alterado
[ ] SECRET_KEY_BASE no mínimo 64 caracteres
Storage / MinIO (B6)
[ ] MinIO não acessível externamente
[ ] Credenciais padrão do MinIO alteradas
[ ] Permissões do volume do Storage corretas
Serviços internos (B7)
[ ] Porta do Analytics apenas localhost
[ ] Vector Docker Socket Read-Only
[ ] Porta do Supavisor apenas interna
[ ] Logflare Tokens não estão no padrão
Studio (B8)
[ ] Em produção desativado OU apenas via SSH Tunnel
[ ] Senha do dashboard forte
Parte C - Configuração do stack
[ ] docker-compose.yml versionado no Git
[ ] Todas as versões de imagem fixadas (nenhum :latest)
[ ] .env não está no Git
[ ] Permissões do .env: 600
[ ] .env.example no Git como template
[ ] Nenhuma senha padrão
[ ] RLS ativo em todas as tabelas public
[ ] Nenhuma tabela sem policies (exceto intencionalmente)
[ ] Nenhuma policy excessivamente aberta (qual = 'true')
Parte D - Operação e monitoramento
[ ] Job de backup diário ativo
[ ] Backups criptografados
[ ] Backups armazenados externamente (audit-runner ou S3)
[ ] Teste de restore realizado pelo menos uma vez
[ ] Estratégia de retenção configurada
[ ] Job diário de security check no audit-runner
[ ] Alerting em caso de findings críticos
[ ] Review semanal com Claude Code configurado
Parte E - Atualizações e manutenção
[ ] Unattended Upgrades instalado e ativo
[ ] Intervalo de atualização automática configurado para diário
[ ] Imagens do Supabase fixadas (sem :latest)
[ ] Nenhuma imagem do Supabase com mais de 90 dias
[ ] Commit de atualização nos últimos 45 dias
[ ] Script auto-patch para PostgreSQL minor patches ativo
[ ] Cron auto-patch DEPOIS do cron de backup (03:00 após 02:00)
[ ] Monitor de releases de segurança no audit-runner ativo (diário)
[ ] Trivy instalado no audit-runner
[ ] Cron de verificação de manutenção no audit-runner (semanal segunda-feira)
[ ] Certificado TLS válido por pelo menos 14 dias
[ ] Uso de disco abaixo de 85%
Parte E - Atualizações e manutenção
Um stack self-hosted que não é atualizado regularmente acumula vulnerabilidades de segurança. CVEs não corrigidas no PostgreSQL, Kong ou GoTrue são vetores de ataque reais. Ao mesmo tempo, atualizações podem introduzir breaking changes que derrubam o stack.
Por isso, o processo de atualização precisa de regras claras: o que é atualizado e quando, como se testa, e como se garante que nada seja esquecido.
E1 - Três níveis de atualização
O stack tem três níveis de atualização independentes, com ritmos e riscos diferentes.
Nível 1: OS (Ubuntu)
│ O quê: Kernel, pacotes do sistema, OpenSSL, Docker Engine
│ Ritmo: Patches de segurança semanais, atualização completa mensal
│ Risco: Baixo (apt upgrade é estável)
│ Método: apt update && apt upgrade
│
Nível 2: Serviços Supabase (Docker Images)
│ O quê: PostgreSQL, Kong, GoTrue, PostgREST, Realtime, Storage, etc.
│ Ritmo: Mensal (ciclo de releases do Supabase)
│ Risco: Médio a alto (breaking changes entre versões)
│ Método: Alterar image tags no docker-compose.yml, pull, restart
│
Nível 3: Reverse Proxy e ferramentas
│ O quê: Caddy, iptables, GPG, nmap, jq
│ Ritmo: Em caso de security advisories ou trimestralmente
│ Risco: Baixo
│ Método: apt upgrade (Caddy via repositório próprio)
Condição verificável:
# OS: Quando foi o último apt upgrade?
stat -c %y /var/cache/apt/pkgcache.bin
# Expectativa: menos de 7 dias atrás
# Supabase: Quais versões de imagem estão rodando?
cd /opt/supabase && docker compose images --format '{{.Repository}}:{{.Tag}}'
# Versão do Caddy
caddy version
Cenário de falha: Um PostgreSQL não atualizado com uma vulnerabilidade conhecida de Remote Code Execution (como CVE-2023-5869) pode ser explorado por um atacante, mesmo que o RLS esteja configurado corretamente. Um Kong desatualizado com uma vulnerabilidade conhecida de auth bypass pode contornar a validação de JWT.
E2 - Atualizações no nível do OS
Semanalmente: patches de segurança (automático)
# Instalar e configurar Unattended Upgrades
sudo apt install -y unattended-upgrades
# Configuração: apenas Security Updates automáticos
sudo tee /etc/apt/apt.conf.d/50unattended-upgrades > /dev/null << 'EOF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}-security";
};
// Reboot automático se necessário (ex.: atualização de kernel)
// Ativar somente se for aceitável que o servidor
// reinicie brevemente às 4h da manhã
Unattended-Upgrade::Automatic-Reboot "false";
// Notificação por e-mail sobre atualizações
Unattended-Upgrade::Mail "ops@example.com";
Unattended-Upgrade::MailReport "on-change";
EOF
# Ativar atualizações automáticas
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
Mensalmente: atualização completa do sistema (manual, com verificação)
# Primeiro verificar o que será atualizado
apt list --upgradable
# Então atualizar
sudo apt update && sudo apt upgrade -y
# Atualização do Docker Engine (separada, via repositório Docker)
sudo apt install --only-upgrade docker-ce docker-ce-cli containerd.io
# Após atualizações de kernel: reboot necessário?
if [ -f /var/run/reboot-required ]; then
echo "REBOOT ERFORDERLICH"
fi
Condição verificável:
# Unattended Upgrades ativo?
systemctl is-active unattended-upgrades
# Últimas atualizações automáticas
cat /var/log/unattended-upgrades/unattended-upgrades.log | tail -20
# Atualizações de segurança pendentes?
apt list --upgradable 2>/dev/null | grep -i security | wc -l
# Expectativa: 0
E3 - Atualizações dos serviços Supabase
O Supabase publica aproximadamente a cada mês novas Docker images. O processo de atualização precisa ser controlado, pois breaking changes entre versões são possíveis.
Workflow:
1. Ler as release notes (github.com/supabase/supabase/releases)
2. Inserir os novos image tags no docker-compose.yml
3. Testar em staging/teste (ou: backup + plano de rollback)
4. Criar backup
5. docker compose pull
6. docker compose down && docker compose up -d
7. Verificar health checks
8. Atualizar baselines
Verificar versões atuais vs. disponíveis:
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"
Procedimento seguro de atualização:
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 em caso de falha:
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
Condição verificável:
# 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
Cenário de falha: O Supabase GoTrue v2.170.0 teve uma alteração no tratamento de Refresh Token que quebrou clientes mais antigos. Sem leitura prévia das release notes e sem backup, isso teria causado uma indisponibilidade. Atualizações de versão major do PostgreSQL (ex.: 15 -> 16) exigem um ciclo de pg_dump/pg_restore - uma simples troca de image tag não é suficiente.
E3b - Patching automático (Nível 1)
Nem todas as atualizações exigem intervenção humana. Minor patches do PostgreSQL e atualizações do Caddy são de baixo risco e podem ser aplicados automaticamente durante a noite.
Modelo de dois níveis:
NÍVEL 1 - AUTOMÁTICO (auto-patch.sh, diário 03:00):
OS Security Patches (unattended-upgrades)
PostgreSQL Minor Patches (15.8.1.x -> 15.8.1.y)
Caddy Updates
-> E-mail informativo após patch bem-sucedido
-> E-mail de alarme em caso de falha no health check
-> Rollback automático em caso de falha
NÍVEL 2 - MANUAL (/supabase-update, dentro de 24h após alerta):
GoTrue/Auth (breaking changes possíveis)
PostgREST (comportamento de queries pode mudar)
Kong (roteamento pode mudar)
Realtime, Storage, Supavisor
PostgreSQL MAJOR (15 -> 16, requer pg_dump/pg_restore)
O script auto-patch.sh roda diariamente às 03:00 (após o backup às 02:00) e:
- Verifica se existe um backup atual (cancela se não existir)
- Salva o estado atual (docker-compose.yml, versões de imagem)
- Verifica se há uma nova minor image do PostgreSQL disponível
- Aplica a atualização e executa um health check
- Em caso de falha: rollback automático para a versão anterior
- Envia um e-mail informativo (sucesso) ou e-mail de alarme (falha)
Condição verificável:
# 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
Cronograma (servidor de produção):
02:00 diário -> Backup (DB + Storage + externo)
03:00 diário -> Auto-Patch (PostgreSQL Minor + Caddy)
04:00 mensal -> Teste de restore
E4 - Cronograma de atualizações e responsabilidades
Semanalmente (automático):
[ ] OS Security Patches (unattended-upgrades)
[ ] Audit-Runner verifica se patches foram aplicados
Mensalmente (manual, planejado):
[ ] Verificar release notes do Supabase
[ ] Avaliar novos image tags
[ ] Criar backup
[ ] Executar atualização
[ ] Health checks
[ ] Atualizar baselines
Trimestralmente (review):
[ ] Verificar versão do Caddy
[ ] Verificar versão do Docker Engine
[ ] Verificar versão do Node.js (para Claude Code no audit-runner)
[ ] Avaliar versão major do PostgreSQL
[ ] Toda a toolchain atualizada?
Em caso de security advisories (imediato):
[ ] CVE afeta nosso stack?
[ ] Identificar imagem/pacote afetado
[ ] Patch disponível?
[ ] Executar atualização de emergência
Condição verificável:
# 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 de releases de segurança (diário)
O maior ponto cego no self-hosting não é a configuração inicial, mas sim o fato de perder security patches. Quando o Supabase GoTrue publica uma correção de auth bypass, a equipe precisa agir dentro de 24 horas, não depois de uma semana.
No audit-runner roda diariamente um script que verifica os GitHub Releases de todos os componentes do Supabase e alerta imediatamente por e-mail em caso de security releases.
Componentes verificados:
supabase/auth (GoTrue) -> patches de segurança frequentes
PostgREST/postgrest -> camada API
supabase/realtime -> WebSocket
supabase/storage-api -> File Storage
Kong/kong -> API Gateway
supabase/edge-runtime -> Edge Functions
supabase/postgres -> imagem do banco de dados
supabase/supavisor -> connection pooler
moby/moby (Docker Engine) -> container runtime
Três camadas de verificação:
Camada 1: GitHub Releases
-> Existe uma nova versão?
-> As release notes contêm "security"/"CVE"?
Camada 2: Trivy Container Scan
-> Verifica cada Docker image em execução contra NVD/GitHub Advisories
-> Encontra CVEs em todas as dependências (pacotes do OS, libraries)
Camada 3: OSV API
-> Verifica vulnerabilidades no nível da aplicação para GoTrue, PostgREST etc.
-> Complementa o Trivy com CVEs específicos de pacotes
Como funciona:
Diariamente 07:00 (cron do audit-runner)
│
├── Buscar versões atuais do servidor de produção
├── GitHub API: verificar últimos releases de cada componente
├── Verificar release notes para "security", "CVE", "vulnerability"
│
├── Security release encontrado?
│ -> E-mail IMEDIATO para ops@
│ -> "Ação necessária dentro de 24h"
│
└── Release normal encontrado?
-> Resumo semanal (segunda-feira)
Mecanismo de cache: O script memoriza releases já reportados, para que o mesmo e-mail não seja enviado todos os dias. Apenas um NOVO release gera outro alerta.
Condição verificável:
# 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"
Cenário de falha: Em janeiro de 2024, o CVE-2023-5869 foi publicado para o PostgreSQL (Remote Code Execution). Quem não tinha um monitor de releases e verificava atualizações apenas mensalmente ficou vulnerável por 3-4 semanas. Com o monitor diário, o e-mail teria chegado no dia seguinte à publicação do release.
E6 - Audit-Runner como vigilante de atualizações
O audit-runner monitora se as atualizações estão sendo realizadas e informa a equipe quando algo está atrasado.
No audit-runner roda um script semanal que verifica o seguinte:
#!/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 no 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
Quando o Claude notifica:
O Claude Code no audit-runner envia notificações em três casos:
IMEDIATO (e-mail para ops@):
- Unattended Upgrades não está ativo
- Certificado TLS < 14 dias
- Security update pendente e com mais de 3 dias
SEMANAL (relatório de manutenção):
- Reboot necessário
- apt update atrasado
- Imagens do Supabase com mais de 60 dias
MENSAL (lembrete de atualização):
- Release notes do Supabase não verificadas (nenhum update commit há mais de 45 dias)
- Review trimestral pendente
Conclusão
Fazer Self-Hosting do Supabase é relativamente simples. Operar o Supabase com segurança exige regras claras de arquitetura e controle automatizado.
Este runbook separa decisões de infraestrutura (Parte A), arquitetura e proteção de serviços (Parte B), configuração do stack (Parte C), monitoramento contínuo (Parte D) e processos de atualização (Parte E). A combinação de checks determinísticos e análise contextual com Claude Code cobre tanto padrões conhecidos quanto riscos inesperados.
Quem segue esses princípios desde o início constrói uma arquitetura Cert-Ready by Design e economiza rodadas de auditoria posteriores.
Lembrete: As configurações específicas da Hetzner (vSwitch, Cloud Firewall, nomes de interface) podem ser transferidas para provedores brasileiros (Locaweb, Magalu Cloud) e europeus (OVH, Scaleway). Os princípios de arquitetura são independentes do provedor.
Download da checklist de auditoria
Prompt preparado para o Claude Code. Faça upload do arquivo no seu servidor e inicie o Claude Code no diretório do projeto do seu stack Supabase. O Claude Code verificará automaticamente todos os pontos de segurança deste runbook e reportará APROVADO, AVISO ou CRÍTICO.
claude -p "$(cat claude-check-artikel-1-supabase-br.md)" --allowedTools Read,Grep,Glob,Bash
Baixar checklistSumário da série
Este artigo faz parte da nossa série DevOps para stacks de aplicações self-hosted.
- Supabase Self-Hosting Runbook - este artigo
- Next.js sobre Supabase com segurança
- Supabase Edge Functions com segurança
- Trigger.dev Background Jobs em produção segura
- Claude Code como controle de segurança no workflow DevOps
- Security Baseline para todo o stack
No próximo artigo, mostramos como o Next.js pode ser operado com segurança sobre o Supabase, sem cometer erros típicos em Server Actions, Auth-Handling e acessos à API.

Bert Gogolin
Diretor Executivo, Gosign
AI Governance Briefing
IA empresarial, regulamentação e infraestrutura - uma vez por mês, diretamente de mim.