Pular para o conteúdo
Infraestrutura & Tecnologia

Supabase Self-Hosting Runbook: arquitetura segura

Supabase Self-Hosting Runbook: configuração Hetzner, Docker Compose, arquitetura de serviços, secrets, RLS e backups.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 30 min de leitura

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.

  1. Supabase Self-Hosting Runbook - este artigo
  2. Next.js sobre Supabase com segurança
  3. Supabase Edge Functions com segurança
  4. Trigger.dev Background Jobs em produção segura
  5. Claude Code como controle de segurança no workflow DevOps
  6. 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çoPortaFunçãoConfiguração crítica de segurança
PostgreSQL5432Armazenamento de dadosRLS em todas as tabelas, listen_address apenas interno
PostgREST3000REST APIAplicação automática de RLS, acesso baseado em roles
GoTrue9999AutenticaçãoJWT expiry, confirmação por email, refresh token rotation
Kong8000API GatewayRate limiting, roteamento, validação JWT
Realtime4000WebSocketAutorização de canais, limites de conexão
Storage5000Object StoragePolíticas de buckets, limites de tamanho de arquivo
Studio3000Painel administrativoAcesso 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 (tipicamente ens10). 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:

  1. Verifica se existe um backup atual (cancela se não existir)
  2. Salva o estado atual (docker-compose.yml, versões de imagem)
  3. Verifica se há uma nova minor image do PostgreSQL disponível
  4. Aplica a atualização e executa um health check
  5. Em caso de falha: rollback automático para a versão anterior
  6. 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 checklist

Sumário da série

Este artigo faz parte da nossa série DevOps para stacks de aplicações self-hosted.

  1. Supabase Self-Hosting Runbook - este artigo
  2. Next.js sobre Supabase com segurança
  3. Supabase Edge Functions com segurança
  4. Trigger.dev Background Jobs em produção segura
  5. Claude Code como controle de segurança no workflow DevOps
  6. 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

Bert Gogolin

Diretor Executivo, Gosign

AI Governance Briefing

IA empresarial, regulamentação e infraestrutura - uma vez por mês, diretamente de mim.

Sem spam. Cancelável a qualquer momento. Política de privacidade

Supabase Self-Hosting DevOps Security PostgreSQL
Compartilhar este artigo

Perguntas frequentes

Quais componentes fazem parte do stack de Self-Hosting do Supabase?

O stack do Supabase é composto por PostgreSQL, PostgREST API, GoTrue Auth, Realtime Server, Storage, Kong API Gateway e Supabase Studio. Opcionalmente, Edge Functions também podem ser adicionadas. Com isso, você opera uma plataforma de backend completa, não apenas um banco de dados.

Por que são necessários dois servidores para Supabase Self-Hosting?

A separação entre sistema de produção e sistema de auditoria impede que um servidor comprometido manipule simultaneamente seus próprios checks de segurança. O sistema de auditoria permanece independente e consegue detectar drift de configuração de forma confiável.

Este artigo faz parte de uma série?

Sim. Este runbook é a parte 1 de uma série DevOps de seis partes para stacks de aplicações self-hosted. A série abrange Supabase, Next.js, Edge Functions, Trigger.dev, Claude Code como controle de segurança e uma Security Baseline.