Supabase Self-Hosting Runbook: arquitectura segura
Supabase Self-Hosting Runbook: configuracion Hetzner, Docker Compose, arquitectura de servicios, secrets, RLS y backups.
Alojar Supabase por cuenta propia es técnicamente bastante sencillo. Operar Supabase de forma segura y estable es considerablemente más exigente.
La razón: Supabase no es una simple base de datos, sino una plataforma backend completa. Un stack self-hosted de Supabase se compone típicamente de varios componentes: PostgreSQL, PostgREST API, GoTrue Auth, Realtime Server, Storage, Kong API Gateway, Supabase Studio y opcionalmente Edge Functions.
Estadística: Según el informe de Supabase de 2025, más del 68% de las instancias self-hosted no tienen RLS activo en todas las tablas públicas, lo que convierte esta verificación en la más frecuentemente omitida.
Con esto se opera de facto una plataforma backend, no solo una base de datos. Consideraciones similares se aplican al self-hosting de modelos de lenguaje.
Este runbook describe un setup de producción mínimo que puede operarse de forma segura y que al mismo tiempo sigue siendo automatizable. Cada paso contiene una implementación concreta, una condición verificable y un escenario de fallo.
Nota sobre el proveedor de hosting: Este runbook utiliza Hetzner Cloud como ejemplo de infraestructura porque Hetzner está ampliamente extendido en el ámbito germanoparlante, ofrece centros de datos en Alemania y tiene una buena relación calidad-precio. Sin embargo, los principios de arquitectura son independientes del proveedor. Los elementos específicos de Hetzner (vSwitch, Cloud Firewall API, Robot Panel) se pueden trasladar directamente a proveedores españoles y europeos: Arsys/IONOS Cloud Network (ES), OVH vRack (ES - datacenter Madrid), Stackscale Private Network (ES), Acens Cloud (ES). Cuando un paso es específico de Hetzner, lo indicamos.
De un vistazo - Artículo 1 de 6 de la serie DevOps Runbook
- Arquitectura de dos servidores separa producción y auditoría
- Docker Compose con versiones de imágenes fijadas
- Siete servicios (PostgreSQL, PostgREST, GoTrue, Kong, Realtime, Storage, Studio)
- RLS en todas las tablas públicas
- Backups cifrados diarios almacenados externamente
Índice de la serie
Esta guía forma parte de nuestra serie de runbooks DevOps para app stacks self-hosted modernos.
- Supabase Self-Hosting Runbook - este artículo
- Next.js sobre Supabase de forma segura
- Supabase Edge Functions de forma segura
- Trigger.dev Background Jobs en producción segura
- Claude Code como control de seguridad en el workflow DevOps
- Security Baseline para todo el stack
El artículo 1 cubre infraestructura, servicios y configuración del stack. Los artículos siguientes se construyen sobre ella.
Resumen de servicios
| Servicio | Puerto | Función | Configuración crítica de seguridad |
|---|---|---|---|
| PostgreSQL | 5432 | Almacenamiento de datos | RLS en todas las tablas, listen_address solo interno |
| PostgREST | 3000 | REST API | Aplicación automática de RLS, acceso basado en roles |
| GoTrue | 9999 | Autenticación | JWT expiry, confirmación por email, refresh token rotation |
| Kong | 8000 | API Gateway | Rate limiting, enrutamiento, validación JWT |
| Realtime | 4000 | WebSocket | Autorización de canales, límites de conexión |
| Storage | 5000 | Object Storage | Políticas de buckets, límites de tamaño de archivo |
| Studio | 3000 | Panel de administración | Acceso solo interno (SSH Tunnel/VPN) |
Arquitectura objetivo
Un setup estable separa al menos dos áreas de responsabilidad.
Internet
|
| HTTPS (443)
|
Reverse Proxy (Caddy / Nginx / Traefik)
|
| TLS terminado
|
Kong API Gateway
|
+-- GoTrue (Auth)
+-- PostgREST (API)
+-- Realtime (WebSocket)
+-- Storage (Object Store compatible con S3)
|
PostgreSQL
|
+-- RLS Policies
En paralelo funciona un segundo sistema separado:
Audit Server
|
+-- Security Checks (Lynis, Trivy, Port Scans)
+-- Drift Detection (Config Diffs contra Baseline)
+-- Claude Code Review (análisis contextual)
+-- Monitoring (métricas, alertas)
+-- Backup Verification (pruebas de restore)
Por qué es necesaria esta separación: si el sistema de producción y el sistema de auditoría son idénticos, un servidor comprometido puede manipular simultáneamente también sus propias verificaciones de seguridad.
Parte A - Decisiones de infraestructura
Estas decisiones se toman una sola vez y constituyen el fundamento de todo lo demás.
A1 - Separar la infraestructura: dos servidores
Implementación
Operar al menos dos servidores, físicos o como VMs separadas:
supabase-prod (Hetzner Cloud CX32 o superior)
audit-runner (Hetzner Cloud CX22 es suficiente)
Específico de Hetzner: En la Hetzner Cloud Console, crear dos instancias separadas en “Servers”, ambas en el mismo proyecto y la misma ubicación (p. ej. fsn1). Con otros proveedores: dos VMs en la misma región/zona.
supabase-prod aloja el stack completo de Supabase y PostgreSQL. audit-runner aloja los Security Checks, Monitoring, Drift Detection y análisis con Claude Code.
Condición verificable
# Ambos servidores deben ser hosts separados
ssh supabase-prod hostname
ssh audit-runner hostname
# Expectativa: nombres de host e IPs diferentes
Escenario de fallo
Si la auditoría y la producción se ejecutan en el mismo servidor y un atacante obtiene acceso root, puede eliminar logs, manipular los resultados de los Security Checks y suprimir alertas. La vulneración pasa desapercibida.
A2 - Configurar red privada
Implementación
Ambos servidores se comunican internamente a través de una red privada. Los servicios de Supabase solo son accesibles a través de estas IPs internas.
Específico de Hetzner: En “Networks”, crear un vSwitch o un Cloud Network con la subred
10.0.1.0/24. Asignar ambos servidores a la red. Hetzner crea automáticamente una interfaz (normalmenteens10). En Arsys/IONOS (ES): Cloud Network. En OVH (ES): vRack. En Stackscale (ES): Private Network.
# Configurar la interfaz privada en ambos servidores
# /etc/network/interfaces.d/60-private.cfg (específico de Hetzner)
auto ens10
iface ens10 inet static
address 10.0.1.10/24 # supabase-prod
# address 10.0.1.11/24 # audit-runner
# Activar la red
systemctl restart networking
# Verificar
ip addr show ens10
ping 10.0.1.11 # desde el servidor prod
Condición verificable
# Desde audit-runner: ¿interfaz interna activa?
ssh audit-runner "ip addr show ens10 | grep 10.0.1.11"
# PostgreSQL solo debe escuchar internamente
ssh supabase-prod "ss -tlnp | grep 5432"
# Expectativa: 10.0.1.10:5432, NO 0.0.0.0:5432
Escenario de fallo
Si PostgreSQL escucha en 0.0.0.0:5432 y el firewall tiene un error, la base de datos es directamente accesible desde Internet. Con la clave service_role o una contraseña débil de Postgres, toda la base de datos queda comprometida.
A3 - Reverse Proxy con TLS
Implementación
Delante de Kong se sitúa un Reverse Proxy que termina el TLS y es el único servicio accesible desde el exterior. Aquí se usa Caddy como ejemplo porque incluye 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
}
}
Alternativa con 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;
# Soporte WebSocket para Realtime
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
}
Condición verificable
# ¿TLS activo y correctamente configurado?
curl -I https://app.example.com
# Expectativa: HTTP/2 200, cabecera Strict-Transport-Security presente
# Verificar versión TLS
openssl s_client -connect app.example.com:443 -tls1_2 </dev/null 2>&1 | grep "Protocol"
# Fecha de expiración del certificado
echo | openssl s_client -connect app.example.com:443 2>/dev/null | \
openssl x509 -noout -enddate
# Expectativa: notAfter al menos 14 días en el futuro
# ¿Cabeceras de seguridad presentes?
curl -sI https://app.example.com | grep -E \
"X-Frame-Options|Strict-Transport-Security|X-Content-Type-Options"
Escenario de fallo
Sin TLS, los tokens de autenticación circulan en texto plano por la red. Cualquiera en el mismo segmento de red puede interceptarlos (Man-in-the-Middle). Sin renovación automática de certificados, el certificado expira tras 90 días y la aplicación deja de ser accesible.
A4 - Doble firewall
Implementación
Dos niveles que se protegen mutuamente:
Nivel 1: Cloud Firewall (delante del servidor)
Específico de Hetzner: En “Firewalls”, crear un nuevo firewall y asignarlo a ambos servidores. En Arsys/IONOS (ES): Cloud Firewall. En OVH (ES): Firewall Network. En Stackscale (ES): Security Groups.
# Reglas de Hetzner Cloud Firewall para supabase-prod
Inbound:
TCP 443 desde 0.0.0.0/0 (HTTPS)
TCP 22 desde ADMIN_IP/32 (SSH solo desde Admin)
TCP ALL desde 10.0.1.0/24 (red interna)
Outbound:
ALL hacia 0.0.0.0/0 (Updates, DNS, Let's Encrypt)
Nivel 2: Host Firewall (en el 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
# Conexiones establecidas
-A INPUT -m state --state ESTABLISHED,RELATED -j ACCEPT
# SSH solo desde IP de Admin
-A INPUT -p tcp --dport 22 -s ADMIN_IP -j ACCEPT
# HTTPS
-A INPUT -p tcp --dport 443 -j ACCEPT
# Red interna (todos los puertos)
-A INPUT -s 10.0.1.0/24 -j ACCEPT
# Descartar todo lo demás (Default Policy)
COMMIT
# Activar el firewall
apt install iptables-persistent
iptables-restore < /etc/iptables/rules.v4
# Guardar baseline para Drift Detection
iptables-save > /root/firewall-baseline.txt
Condición verificable
# Desde el exterior: solo puerto 443 abierto
nmap -p 22,80,443,5432,8000,9000 app.example.com
# Expectativa: solo 443 open (22 solo desde IP de Admin)
# En el servidor: ¿reglas activas?
iptables -L -n | grep -c "DROP"
# Expectativa: al menos 1 (Default DROP Policy)
# Drift Detection: ¿ha cambiado el firewall?
iptables-save | diff /root/firewall-baseline.txt -
# Expectativa: sin diferencias
Escenario de fallo
Un único firewall puede estar mal configurado. Un iptables -F (flush) en el servidor abre todos los puertos si no existe un Cloud Firewall. A la inversa, un Cloud Firewall no protege contra procesos que abren nuevos puertos en el propio servidor si no hay un Host Firewall activo.
A5 - Asegurar el acceso SSH
Implementación
# /etc/ssh/sshd_config
PasswordAuthentication no
PermitRootLogin no
PubkeyAuthentication yes
MaxAuthTries 3
LoginGraceTime 30
AllowUsers deploy
# Depositar la clave SSH en el 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
# Recargar SSHD
systemctl reload sshd
Condición verificable
# El login con contraseña debe fallar
ssh -o PasswordAuthentication=yes -o PubkeyAuthentication=no deploy@app.example.com
# Expectativa: Permission denied
# El login como root debe fallar
ssh root@app.example.com
# Expectativa: Permission denied
# Verificar configuración
sshd -T | grep -E "passwordauthentication|permitrootlogin"
# Expectativa: passwordauthentication no, permitrootlogin no
Escenario de fallo
Los accesos SSH basados en contraseña son atacados continuamente desde Internet (fuerza bruta). Una contraseña débil se descubre normalmente en cuestión de horas. El login como root significa que un atacante obtiene inmediatamente control total sobre el servidor.
Parte B - Configurar y asegurar los servicios de Supabase
Un stack self-hosted de Supabase se compone de más de 10 servicios. Cada uno tiene sus propias variables de entorno, sus propios roles de base de datos y sus propios requisitos de seguridad. El docker-compose.yml oficial de Supabase tiene más de 400 líneas y contiene muchos ajustes que no parecen relevantes para la seguridad pero que lo son.
Esta sección explica cada servicio, su función, su configuración relevante para la seguridad y los errores típicos durante el setup.
Visión general de servicios
Internet
|
| HTTPS (443)
|
Reverse Proxy (Caddy/Nginx) <- Parte A3
|
Kong (API Gateway) <- enruta a todos los servicios
|
+-- GoTrue (Auth) <- /auth/v1/*
+-- PostgREST (REST API) <- /rest/v1/*
+-- Realtime <- /realtime/v1/*
+-- Storage API <- /storage/v1/*
+-- Edge Functions <- /functions/v1/*
+-- Studio (Dashboard) <- / (protegido con 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 <- Base de datos con Init-Scripts
|
+-- Roles: anon, authenticated, service_role,
authenticator, supabase_admin, supabase_auth_admin,
supabase_storage_admin
B0 - Generar secrets (antes del primer arranque)
Esto debe ocurrir ANTES de iniciar todos los servicios. El .env.example oficial contiene marcadores de posición. Estos deben ser reemplazados por valores generados.
# JWT Secret (compartido por GoTrue, PostgREST, Realtime, Kong)
JWT_SECRET=$(openssl rand -base64 32)
# Contraseña de Postgres
POSTGRES_PASSWORD=$(openssl rand -base64 24)
# Contraseña del Dashboard (Basic Auth via Kong)
DASHBOARD_PASSWORD=$(openssl rand -base64 16)
# Tokens de Logflare
LOGFLARE_PUBLIC_ACCESS_TOKEN=$(openssl rand -hex 16)
LOGFLARE_PRIVATE_ACCESS_TOKEN=$(openssl rand -hex 16)
# Contraseña root de MinIO (si se usa S3 Storage)
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)
# Claves Anon y Service Role (JWTs, generados con el JWT_SECRET)
# Usa https://supabase.com/docs/guides/self-hosting/docker#generate-api-keys
# o genéralos manualmente con jwt.io y el JWT_SECRET
Regla de seguridad: Todos los secrets se almacenan en el archivo .env en el servidor (permisos 600, no en Git). Supabase utiliza un único JWT_SECRET para todos los servicios. Si este secret se ve comprometido, GoTrue, PostgREST y Realtime se ven afectados simultáneamente.
Condición verificable
# ¿Todos los secrets requeridos están configurados?
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 no está configurado o tiene valor por defecto"
fi
done
# ¿No hay contraseñas idénticas?
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: Algunos secrets tienen valores idénticos"
fi
Escenario de fallo
Los secrets por defecto del .env.example son públicamente conocidos. Un atacante puede generar tokens válidos con el JWT_SECRET por defecto y obtener acceso total a la API. Con el SERVICE_ROLE_KEY por defecto elude además todas las RLS Policies.
B1 - PostgreSQL: base de datos y roles
Qué hace este servicio
PostgreSQL es la base de datos central. En el contexto de Supabase tiene un papel especial: no solo almacena datos de la aplicación, sino también datos de autenticación (GoTrue), configuración de Realtime, metadatos de Storage y logs de Analytics. Los Init-Scripts crean schemas y roles especiales.
Configuración relevante para la seguridad
db:
image: supabase/postgres:15.8.1.085 # Imagen propia de Supabase, fijar versión
restart: unless-stopped
ports:
- "10.0.1.10:5432:5432" # SOLO interfaz interna
volumes:
# Init-Scripts (crean schemas, roles, extensiones)
- ./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
# Datos 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}
Los Init-Scripts crean los siguientes roles:
postgres -> Superuser (solo para admin, nunca para servicios)
anon -> Requests no autenticados (via PostgREST)
authenticated -> Requests autenticados (via PostgREST)
service_role -> Elude RLS (para operaciones administrativas)
authenticator -> PostgREST usa este rol para conectarse
supabase_admin -> Rol interno de admin (Realtime, Analytics)
supabase_auth_admin -> Específico de GoTrue (schema auth)
supabase_storage_admin -> Específico de Storage (schema storage)
Qué puede salir mal
El archivo roles.sql define los grants para cada rol. Si este archivo se modifica (por ejemplo, para resolver rápidamente un problema), los roles pueden obtener demasiados permisos. El rol anon solo debe tener los permisos que las RLS Policies permiten explícitamente.
Condición verificable
# Verificar roles y sus permisos
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 NO debe ser superuser
docker compose exec -T db psql -U postgres -c \
"SELECT rolname, rolsuper FROM pg_roles WHERE rolname = 'anon' AND rolsuper = true;"
# Expectativa: ninguna fila
# Postgres escucha SOLO internamente
ss -tlnp | grep 5432
# Expectativa: 10.0.1.10:5432, NO 0.0.0.0:5432
B2 - Kong: API Gateway y enrutamiento
Qué hace este servicio
Kong es el punto de entrada central para todas las peticiones API. Enruta según la ruta URL a los servicios correspondientes, gestiona la validación JWT y protege el dashboard de Studio con Basic Auth.
Configuración relevante para la seguridad
kong:
image: kong:2.8.1 # Fijar versión
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000" # SOLO localhost (Reverse Proxy delante)
- "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, sin BD
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'
El kong.yml define el enrutamiento:
/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
Qué puede salir mal
Kong en 0.0.0.0:8000 en lugar de 127.0.0.1:8000 significa que la API es accesible directamente sin Reverse Proxy (sin TLS). Una contraseña de dashboard débil da acceso via Basic Auth al Studio y con ello a toda la base de datos. El kong.yml puede configurarse de forma que la validación JWT esté desactivada para ciertas rutas.
Condición verificable
# ¿Kong solo en localhost?
ss -tlnp | grep 8000
# Expectativa: 127.0.0.1:8000
# ¿Contraseña del dashboard suficientemente fuerte?
DASH_PW_LEN=$(grep "DASHBOARD_PASSWORD" .env | cut -d= -f2 | wc -c)
[ "$DASH_PW_LEN" -lt 16 ] && echo "AVISO: Contraseña del dashboard demasiado corta"
# kong.yml: ¿JWT Validation activa en todas las rutas API?
grep -A5 "key-auth" volumes/api/kong.yml | head -20
B3 - GoTrue: autenticación
Qué hace este servicio
GoTrue gestiona el registro de usuarios, login, Magic Links, OAuth, MFA y la emisión de tokens. Es el único servicio que emite JWTs y tiene un usuario de BD propio (supabase_auth_admin) con acceso al schema auth.
Configuración relevante para la seguridad
auth:
image: supabase/gotrue:v2.184.0 # Fijar versión
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
# Base de datos (usuario admin propio)
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} # MAXIMO 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 si la app es cerrada
GOTRUE_EXTERNAL_EMAIL_ENABLED: ${ENABLE_EMAIL_SIGNUP}
GOTRUE_MAILER_AUTOCONFIRM: false # SIEMPRE false en producción
# SMTP (para Magic Links, confirmación por email)
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}
# Rutas URL del Mailer (deben coincidir con el enrutamiento de 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 después de Auth)
GOTRUE_SITE_URL: ${SITE_URL} # URL de vuestra app Next.js
GOTRUE_URI_ALLOW_LIST: ${ADDITIONAL_REDIRECT_URLS}
# Refresh Token Rotation (previene reutilización de tokens)
GOTRUE_SECURITY_REFRESH_TOKEN_ROTATION_ENABLED: true
GOTRUE_SECURITY_REFRESH_TOKEN_REUSE_INTERVAL: 10
Configuración SMTP: por qué es relevante para la seguridad
Sin un servidor SMTP funcional no se pueden enviar emails de confirmación. Si se configura GOTRUE_MAILER_AUTOCONFIRM: true para evitarlo, cualquiera puede registrarse con cualquier dirección de email. Esto significa: sin verificación de identidad.
Condición verificable
# Health Check de GoTrue
docker compose exec -T auth wget --no-verbose --tries=1 --spider http://localhost:9999/health
# ¿JWT Expiry no superior a 3600?
grep "JWT_EXPIRY\|JWT_EXP" .env | head -1
# Expectativa: 3600 o menos
# ¿Autoconfirm desactivado?
grep "AUTOCONFIRM" .env
# Expectativa: false
# ¿SMTP configurado (no vacío)?
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á vacío"
done
# ¿Refresh Token Rotation activo?
grep "REFRESH_TOKEN_ROTATION" .env docker-compose.yml 2>/dev/null
# Expectativa: true
Escenario de fallo
Con AUTOCONFIRM=true y DISABLE_SIGNUP=false, cualquiera puede crear una cuenta y usarla inmediatamente sin confirmar la dirección de email. Un atacante puede registrarse con direcciones arbitrarias y obtiene inmediatamente el rol authenticated en la base de datos. Sin Refresh Token Rotation, un refresh token robado puede ser utilizado indefinidamente.
B4 - PostgREST: REST API
Qué hace este servicio
PostgREST genera automáticamente una REST API a partir del schema de PostgreSQL. Es el punto de acceso principal para datos. La seguridad reside primariamente en los roles de PostgreSQL y las RLS Policies, no en PostgREST en sí mismo.
Configuración relevante para la seguridad
rest:
image: postgrest/postgrest:v14.1 # Fijar versión
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}
Cómo trabaja PostgREST con los roles:
Request sin JWT -> PostgREST usa rol "anon"
Request con JWT -> PostgREST cambia al rol del JWT (p. ej. "authenticated")
Request con service_role JWT -> PostgREST usa "service_role" (elude RLS)
El usuario authenticator se conecta a la BD y cambia mediante SET ROLE al rol correspondiente. Esto significa: los grants en los roles anon y authenticated son la verdadera capa de seguridad.
Condición verificable
# ¿PostgREST usa el rol authenticator (no postgres)?
grep "PGRST_DB_URI" docker-compose.yml | grep "authenticator"
# Expectativa: Sí
# ¿Schemas definidos explícitamente (no todos)?
grep "PGRST_DB_SCHEMAS" .env
# Expectativa: public,storage,graphql_public (no vacío = todos los schemas)
Escenario de fallo
Si PGRST_DB_URI usa el superusuario postgres en lugar de authenticator, cada request tiene permisos de superusuario y RLS es ineficaz. Si PGRST_DB_SCHEMAS está vacío, PostgREST expone todos los schemas incluyendo los internos de Supabase (auth, _realtime, _analytics).
B5 - Realtime: suscripciones WebSocket
Qué hace este servicio
Realtime permite suscripciones basadas en WebSocket a cambios en la base de datos. Usa el usuario supabase_admin y el schema _realtime.
Configuración relevante para la seguridad
realtime:
container_name: realtime-dev.supabase-realtime # El nombre es relevante para el 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 # CAMBIAR en producción
API_JWT_SECRET: ${JWT_SECRET}
SECRET_KEY_BASE: ${SECRET_KEY_BASE} # mín. 64 caracteres
SEED_SELF_HOST: "true"
RUN_JANITOR: "true"
Qué puede salir mal
DB_ENC_KEY: supabaserealtime es un valor por defecto. En producción debe cambiarse. SECRET_KEY_BASE debe tener al menos 64 caracteres, de lo contrario el servicio no arranca o es inseguro. Realtime usa supabase_admin, lo que significa que internamente tiene acceso completo a la BD. La seguridad reside en la validación JWT: solo usuarios autenticados pueden abrir suscripciones, y RLS determina qué filas ven.
Condición verificable
# ¿DB_ENC_KEY no tiene valor por defecto?
grep "DB_ENC_KEY" docker-compose.yml
# NO "supabaserealtime"
# ¿SECRET_KEY_BASE suficientemente largo?
SKB_LEN=$(grep "SECRET_KEY_BASE" .env | cut -d= -f2 | wc -c)
[ "$SKB_LEN" -lt 64 ] && echo "CRITICO: SECRET_KEY_BASE demasiado corto ($SKB_LEN caracteres)"
# Health Check de Realtime
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 y MinIO (S3)
Qué hace este servicio
Storage gestiona la subida y descarga de archivos. Por defecto almacena archivos localmente en el volumen. Para producción se recomienda un backend compatible con S3 (MinIO self-hosted o un servicio S3 libre del Cloud Act).
Almacenamiento local (predeterminado)
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
ENABLE_IMAGE_TRANSFORMATION: "true"
IMGPROXY_URL: http://imgproxy:5001
MinIO para backend S3 (producción)
Si usáis MinIO, se añade un servicio adicional:
# docker-compose.s3.yml (adicional al Compose principal)
minio:
image: minio/minio:latest # Fijar versión en producción
restart: unless-stopped
ports:
- "127.0.0.1:9000:9000" # API, SOLO localhost
- "127.0.0.1:9001:9001" # Console, SOLO 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"
Luego en la configuración de 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
Condición verificable
# Health Check de Storage
docker compose exec -T storage wget --no-verbose --tries=1 --spider http://localhost:5000/status
# ¿MinIO no accesible desde el exterior (si se usa)?
ss -tlnp | grep 9000
# Expectativa: 127.0.0.1:9000 (no 0.0.0.0)
# ¿Credenciales por defecto de MinIO?
grep "MINIO_ROOT" .env | grep -iE "minioadmin\|minio123\|admin"
# Expectativa: sin coincidencias
# ¿El volumen de Storage existe y tiene datos?
ls -la volumes/storage/ 2>/dev/null | head -5
# Bucket Policies de Storage (via MinIO Client)
# mc alias set local http://localhost:9000 $MINIO_ROOT_USER $MINIO_ROOT_PASSWORD
# mc admin policy ls local
Escenario de fallo
MinIO con credenciales por defecto (minioadmin:minioadmin) en 0.0.0.0:9000 significa: cualquiera en Internet puede leer y escribir todos los archivos. La consola de MinIO en el puerto 9001 proporciona adicionalmente una interfaz web para la administración completa. Las Bucket Policies de Storage pueden estar configuradas como “public”, lo que significa que los archivos son accesibles sin autenticación.
B7 - Analytics, Vector y Supavisor (servicios internos)
Qué hacen estos servicios
Estos servicios no son accesibles directamente desde el exterior, pero son relevantes para la seguridad porque tienen acceso privilegiado a la base de datos.
Analytics (Logflare): Recopila y almacena logs de todos los servicios. Usa supabase_admin y el schema _analytics.
Vector: Pipeline de logs que reenvía los logs de Docker a Logflare. Tiene acceso al Docker Socket.
Supavisor: Connection Pooler para PostgreSQL. Gestiona el pool de conexiones y tiene acceso supabase_admin.
Configuración relevante para la seguridad
analytics:
image: supabase/logflare:1.27.0
ports:
- "127.0.0.1:4000:4000" # SOLO 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 expone el puerto de 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}
Qué puede salir mal
Vector con acceso al Docker Socket (/var/run/docker.sock) puede leer los logs de los contenedores. El socket debe montarse en modo Read-Only (:ro). Analytics en el puerto 4000 con tokens por defecto expone los logs de todos los servicios. Supavisor en 0.0.0.0:5432 en lugar de la interfaz interna hace que el Connection Pooler (y con ello PostgreSQL) sea accesible desde el exterior.
Condición verificable
# ¿Analytics solo interno?
ss -tlnp | grep 4000
# Expectativa: 127.0.0.1:4000
# ¿Docker Socket de Vector en Read-Only?
grep "docker.sock" docker-compose.yml | grep ":ro"
# Expectativa: :ro presente
# ¿Puerto de Supavisor solo interno?
ss -tlnp | grep 5432
# Expectativa: 10.0.1.10:5432 (interfaz interna)
# ¿Tokens de Logflare no por defecto?
grep "LOGFLARE.*TOKEN" .env | grep -iE "your-\|change\|example"
# Expectativa: sin coincidencias
B8 - Studio: asegurar el dashboard
Qué hace este servicio
Studio es la interfaz web para la gestión de la base de datos, administración de usuarios y consultas SQL. A través del SERVICE_ROLE_KEY y el acceso directo a Postgres tiene acceso completo a todo.
Configuración relevante para la seguridad
Studio está protegido via Kong con Basic Auth (DASHBOARD_USERNAME/DASHBOARD_PASSWORD). Además, Studio en producción debería no estar en ejecución o solo ser accesible mediante SSH Tunnel.
Opción 1: no iniciar Studio en producción (recomendado)
# En docker-compose.override.yml o eliminar Studio del Compose
# Usar Studio solo localmente con supabase start para desarrollo
Opción 2: Studio solo mediante SSH Tunnel
# Desde la estación de trabajo:
ssh -L 3000:localhost:3000 deploy@supabase-prod
# Luego en el navegador: http://localhost:3000
Condición verificable
# ¿El contenedor Studio está en ejecución? (No debería estarlo en producción)
docker compose ps | grep studio
# Recomendación: no running en producción
# Si Studio está en ejecución: ¿no accesible desde el exterior?
curl -s -o /dev/null -w "%{http_code}" --connect-timeout 3 \
"https://app.example.com:3000" 2>/dev/null
# Expectativa: Timeout o Connection Refused
# ¿Contraseña del dashboard fuerte?
DASH_LEN=$(grep "DASHBOARD_PASSWORD" .env | cut -d= -f2 | wc -c)
[ "$DASH_LEN" -lt 16 ] && echo "AVISO: Contraseña del dashboard demasiado corta"
Escenario de fallo
Studio con una contraseña de dashboard débil y acceso público proporciona a un atacante una interfaz completa de administración de base de datos. Puede ejecutar consultas SQL, eliminar usuarios, desactivar RLS y exportar datos. Es el peor escenario para un stack self-hosted.
Lista de verificación de servicios
Después de configurar todos los servicios, verificar antes del primer uso productivo:
Secrets
[ ] Todos los secrets generados (sin valores por defecto)
[ ] JWT_SECRET al menos 32 caracteres
[ ] POSTGRES_PASSWORD al menos 24 caracteres
[ ] DASHBOARD_PASSWORD al menos 16 caracteres
[ ] SECRET_KEY_BASE al menos 64 caracteres
[ ] MINIO_ROOT_PASSWORD al menos 8 caracteres (si se usa MinIO)
[ ] DB_ENC_KEY cambiado (no "supabaserealtime")
PostgreSQL
[ ] Puerto solo en interfaz interna (10.0.1.10:5432)
[ ] Init-Scripts sin modificar (roles.sql, jwt.sql etc.)
[ ] Roles creados correctamente (anon no es superuser)
Kong
[ ] Puerto solo en localhost (127.0.0.1:8000)
[ ] JWT Validation activa en todas las rutas API
[ ] Contraseña del dashboard fuerte
GoTrue (Auth)
[ ] JWT_EXP máximo 3600
[ ] AUTOCONFIRM false
[ ] SMTP configurado y probado
[ ] SITE_URL y API_EXTERNAL_URL correctos
[ ] Refresh Token Rotation activo
[ ] DISABLE_SIGNUP configurado si la app es cerrada
PostgREST
[ ] Usa rol authenticator (no postgres)
[ ] DB_SCHEMAS definido explícitamente
Realtime
[ ] DB_ENC_KEY cambiado
[ ] SECRET_KEY_BASE al menos 64 caracteres
Storage / MinIO
[ ] MinIO no accesible desde el exterior
[ ] Credenciales por defecto de MinIO cambiadas
[ ] Permisos del volumen de Storage correctos
Servicios internos
[ ] Puerto de Analytics solo localhost
[ ] Docker Socket de Vector en Read-Only
[ ] Puerto de Supavisor solo interno
[ ] Tokens de Logflare no por defecto
Studio
[ ] Desactivado en producción O solo mediante SSH Tunnel
[ ] Contraseña del dashboard fuerte
Parte C - Configurar el stack de Supabase
Estos pasos se refieren a la instalación de Supabase propiamente dicha y sus ajustes relevantes para la seguridad.
C1 - Versionar el despliegue de Supabase
Implementación
Todos los archivos de infraestructura pertenecen a un repositorio Git. Los despliegues se realizan exclusivamente a través de este repositorio, nunca mediante cambios manuales en el servidor.
infra/
docker-compose.yml
.env.example (plantilla, sin secrets reales)
caddy/
Caddyfile
postgres/
migrations/
scripts/
backup.sh
restore.sh
health-check.sh
security-check.sh
runbooks/
supabase-production.md
security-baseline.md
Workflow de despliegue
# En el servidor
cd /opt/supabase
git pull origin main
# Cargar variables de entorno (el archivo solo existe en el servidor)
source .env
# Iniciar/actualizar el stack
docker compose up -d
# Health Check
./scripts/health-check.sh
Condición verificable
# ¿Hay cambios sin commit en el servidor?
cd /opt/supabase && git status --porcelain
# Expectativa: vacío (sin cambios locales)
# ¿Está el servidor actualizado?
git log --oneline -1
# Comparar con remoto
git fetch origin && git diff HEAD origin/main --stat
# Expectativa: sin diferencias
Escenario de fallo
Los cambios manuales en el docker-compose.yml del servidor se sobrescriben en el siguiente git pull o generan conflictos de merge. Peor aún: nadie sabe qué cambio fue realizado cuándo y por quién. Tras la pérdida de un servidor, la configuración no es reproducible.
C2 - docker-compose.yml: stack de producción mínimo
Implementación
Supabase proporciona un archivo Compose de referencia con más de 400 líneas y unos 15 servicios. No todos son necesarios para producción. Estas son las decisiones relevantes para la seguridad:
Stack mínimo (estos servicios son necesarios):
postgres Base de datos
kong API Gateway
gotrue Auth
postgrest REST API
realtime WebSocket (si es necesario)
storage File Storage (si es necesario)
meta Metadatos para PostgREST
No en producción (omitir o solo interno):
studio Admin UI, solo mediante SSH Tunnel o VPN
imgproxy solo si se necesitan transformaciones de imágenes
inbucket solo para pruebas locales de email
Extracto de la configuración relevante para la seguridad:
# docker-compose.yml (extracto, partes relevantes para la seguridad)
services:
postgres:
image: supabase/postgres:15.6.1.143 # Fijar versión
restart: unless-stopped
ports:
- "10.0.1.10:5432:5432" # SOLO interfaz 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 # Fijar versión
restart: unless-stopped
ports:
- "127.0.0.1:8000:8000" # SOLO localhost (Reverse Proxy delante)
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 # Fijar versión
restart: unless-stopped
environment:
GOTRUE_JWT_SECRET: ${JWT_SECRET}
GOTRUE_JWT_EXP: 3600 # 1 hora, no más
GOTRUE_EXTERNAL_EMAIL_ENABLED: true
GOTRUE_MAILER_AUTOCONFIRM: false # Forzar confirmación por email
GOTRUE_DISABLE_SIGNUP: false # Poner en true si el registro está cerrado
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 # Fijar versión
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
Puntos de configuración críticos:
GOTRUE_JWT_EXP: 3600 no superior a 3600 (1h)
GOTRUE_MAILER_AUTOCONFIRM false en producción
GOTRUE_DISABLE_SIGNUP true si no hay registro abierto
REFRESH_TOKEN_ROTATION true (previene reutilización de tokens)
Versiones de imágenes siempre fijar, nunca :latest
Puerto de Postgres solo en interfaz interna
Puerto de Kong solo en localhost (Reverse Proxy delante)
Condición verificable
# ¿Imágenes fijadas (sin :latest)?
grep "image:" docker-compose.yml | grep -c "latest"
# Expectativa: 0
# ¿Postgres solo accesible internamente?
docker compose exec postgres ss -tlnp | grep 5432
# Expectativa: solo 10.0.1.10:5432 o 0.0.0.0:5432 (entonces verificar firewall)
# ¿Postgres no accesible desde el exterior?
nmap -p 5432 app.example.com
# Expectativa: filtered o closed
# ¿JWT Expiry correcto?
grep "GOTRUE_JWT_EXP" .env
# Expectativa: 3600 o menos
# ¿Supabase Studio no accesible desde el exterior?
curl -s -o /dev/null -w "%{http_code}" https://app.example.com:3000
# Expectativa: Timeout o Connection Refused
Escenario de fallo
Las imágenes sin fijar (image: supabase/gotrue:latest) pueden introducir inadvertidamente una nueva versión con un docker compose pull, que contenga breaking changes o una vulnerabilidad conocida. Si Postgres escucha en 0.0.0.0:5432 y el firewall falla temporalmente, toda la base de datos queda accesible en Internet. Si GOTRUE_JWT_EXP está en 86400 (24h), un token robado es válido durante un día entero.
C3 - Gestionar secrets de forma completa y segura
Implementación
Un stack de Supabase tiene al menos estos secrets:
# .env (solo en el servidor, nunca en Git)
# Secrets principales
JWT_SECRET= # mín. 32 caracteres, generado con openssl rand -base64 32
ANON_KEY= # Token JWT con rol anon
SERVICE_ROLE_KEY= # Token JWT con service_role, omite RLS
POSTGRES_PASSWORD= # mín. 24 caracteres, generado
# Dashboard
DASHBOARD_USERNAME= # Login de Supabase Studio
DASHBOARD_PASSWORD= # mín. 16 caracteres
# Email (GoTrue)
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASS=
SMTP_SENDER_NAME=
# Storage (si se usa backend S3)
S3_ACCESS_KEY=
S3_SECRET_KEY=
Generar secrets:
# JWT Secret
openssl rand -base64 32
# Contraseña de Postgres
openssl rand -base64 24
# Claves Anon y Service Role (Supabase CLI)
# O crear JWT manualmente con el JWT_SECRET
Gestión de secrets en el servidor:
# Archivo .env con permisos restrictivos
chmod 600 /opt/supabase/.env
chown deploy:deploy /opt/supabase/.env
# Verificar que .env no está en Git
cat /opt/supabase/.gitignore | grep ".env"
En el repositorio solo se encuentra la plantilla:
# .env.example (en 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=
El mismo principio se aplica a cualquier seguridad de datos en infraestructura de IA empresarial.
Condición verificable
# ¿.env no está en Git?
cd /opt/supabase && git ls-files .env
# Expectativa: vacío
# ¿.env en .gitignore?
grep "^\.env$" .gitignore
# Expectativa: .env
# ¿Permisos de archivo correctos?
stat -c "%a %U" .env
# Expectativa: 600 deploy
# ¿Los secrets tienen longitud suficiente?
awk -F= '{if (length($2) < 16 && $2 != "" && $1 !~ /PORT|HOST|NAME/) print "DEMASIADO CORTO: "$1}' .env
# Expectativa: sin salida
# ¿Sin contraseñas por defecto?
grep -iE "password|secret" .env | grep -iE "change.me|default|example|your.*here"
# Expectativa: sin coincidencias
Escenario de fallo
El problema de seguridad más frecuente en Supabase Self-Hosting no es un exploit de servidor, sino un secret filtrado. Si .env se hace commit en Git y el repositorio es público (o llega a serlo), todos los secrets quedan expuestos. Con la clave SERVICE_ROLE_KEY se puede leer y escribir toda la base de datos sin RLS.
C4 - Verificar las políticas de base de datos
Implementación
En Supabase, gran parte de la seguridad reside en PostgreSQL Row Level Security (RLS), no en el servidor de aplicación. Cada tabla del esquema public debe tener RLS activado.
Encontrar tablas sin RLS:
-- Todas las tablas public sin RLS
SELECT schemaname, tablename, rowsecurity
FROM pg_tables
WHERE schemaname = 'public'
AND rowsecurity = false;
Encontrar tablas con RLS pero sin Policies:
-- RLS activo pero sin policy definida = no es posible el acceso
-- (puede ser intencionado, pero hay que 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 demasiado abiertas:
-- Policies que conceden acceso total a todos los roles
SELECT tablename, policyname, permissive, roles, cmd, qual
FROM pg_policies
WHERE schemaname = 'public'
AND (roles = '{public}' OR qual = 'true');
Verificar el uso de Service Role:
-- ¿Qué roles existen y qué permisos tienen?
SELECT rolname, rolsuper, rolcreaterole, rolcreatedb
FROM pg_roles
WHERE rolname IN ('anon', 'authenticated', 'service_role', 'authenticator');
Condición verificable
# Como script automatizado desde 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: sin tablas (o solo las excluidas intencionadamente)
Escenario de fallo
Una tabla users con rowsecurity = false es completamente legible a través de la API PostgREST para cualquiera con la clave anon. Esto afecta a todas las columnas, incluidas direcciones de email, números de teléfono y otros datos personales. Un simple comando curl con la clave pública anon es suficiente.
Parte D - Operación y monitorización
Estos pasos se ejecutan de forma regular y automatizada.
D1 - Automatizar backups
Implementación
Volcados diarios de PostgreSQL, cifrados y almacenados 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
# Volcado de PostgreSQL
docker compose exec -T postgres pg_dump \
-U postgres \
--format=custom \
--compress=9 \
postgres > "${BACKUP_DIR}/db_${DATE}.dump"
# Respaldar Storage Buckets (si se usa Supabase Storage)
docker compose exec -T storage tar -czf - /var/lib/storage \
> "${BACKUP_DIR}/storage_${DATE}.tar.gz"
# Cifrar
for file in "${BACKUP_DIR}"/*_${DATE}.*; do
gpg --encrypt --recipient "${GPG_RECIPIENT}" "$file"
rm "$file" # Eliminar la versión sin cifrar
done
# Copiar al servidor externo (audit-runner o S3)
rsync -az "${BACKUP_DIR}/"*_${DATE}*.gpg \
deploy@10.0.1.11:/opt/backup-archive/
# Eliminar backups antiguos (local)
find "${BACKUP_DIR}" -name "*.gpg" -mtime +${RETENTION_DAYS} -delete
# Limpiar también en el servidor de backup
ssh deploy@10.0.1.11 \
"find /opt/backup-archive -name '*.gpg' -mtime +${RETENTION_DAYS} -delete"
echo "Backup ${DATE} completado"
# Configurar Cron Job
# crontab -e
0 3 * * * /opt/supabase/scripts/backup.sh >> /var/log/backup.log 2>&1
Condición verificable
# ¿Existe el backup de hoy?
ls -la /opt/backups/*_$(date +%Y-%m-%d)*.gpg
# ¿Ha llegado el backup al servidor externo?
ssh deploy@10.0.1.11 "ls -la /opt/backup-archive/*_$(date +%Y-%m-%d)*.gpg"
# ¿Tamaño del backup plausible (no 0 bytes)?
find /opt/backups -name "*.gpg" -size 0 -print
# Expectativa: sin coincidencias
Escenario de fallo
Almacenar los backups solo en el mismo servidor significa que, si el servidor falla o es cifrado (ransomware), los backups también se pierden. Los backups sin cifrar en un servidor externo son una filtración de datos, ya que el volcado contiene todos los datos de las tablas en texto plano.
D2 - Probar el restore periódicamente
Implementación
Una vez al mes, restaurar un backup en un sistema de prueba.
#!/bin/bash
# scripts/restore-test.sh
set -euo pipefail
BACKUP_FILE=$1 # p. ej. /opt/backup-archive/db_2026-03-01_0300.dump.gpg
# Descifrar
gpg --decrypt "$BACKUP_FILE" > /tmp/restore-test.dump
# Iniciar contenedor de prueba
docker run -d --name restore-test \
-e POSTGRES_PASSWORD=testpassword \
supabase/postgres:15.6.1.143
sleep 10
# Restaurar
docker exec -i restore-test pg_restore \
-U postgres \
--dbname=postgres \
--clean \
--if-exists \
< /tmp/restore-test.dump
# Verificar tablas
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;"
# Limpiar
docker rm -f restore-test
rm /tmp/restore-test.dump
echo "Prueba de restore completada"
Condición verificable
# Ejecutar el script de prueba de restore y verificar el exit code
./scripts/restore-test.sh /opt/backup-archive/db_latest.dump.gpg
echo $?
# Expectativa: 0
# Verificar el log de la última prueba de restore
cat /var/log/restore-test.log | tail -20
Escenario de fallo
Muchos equipos tienen backups que llevan meses ejecutándose, pero nunca han probado un restore. Problemas típicos: formato incorrecto del pg_dump (texto plano en lugar de Custom), permisos faltantes durante el restore, versiones incompatibles de PostgreSQL entre backup y restore. Todo esto solo se descubre cuando realmente se necesita el restore.
D3 - Verificaciones diarias de infraestructura
Implementación
Un script en el audit-runner verifica diariamente el estado del sistema de producción.
#!/bin/bash
# scripts/security-check.sh (se ejecuta en audit-runner)
set -euo pipefail
PROD_HOST="10.0.1.10"
REPORT=""
CRITICAL=0
# 1. Estado de los contenedores
STOPPED=$(ssh deploy@${PROD_HOST} "docker compose ps --format json" | \
jq -r 'select(.State != "running") | .Name')
if [ -n "$STOPPED" ]; then
REPORT+="CRITICO: Contenedores no running: ${STOPPED}\n"
CRITICAL=1
fi
# 2. Puertos abiertos desde el exterior
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: Puertos abiertos inesperados: ${OPEN_PORTS}\n"
CRITICAL=1
fi
# 3. Fecha de expiración del 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: El certificado TLS expira en ${DAYS_LEFT} días\n"
fi
# 4. Drift del Firewall
FIREWALL_DIFF=$(ssh deploy@${PROD_HOST} "iptables-save" | \
diff /opt/baselines/firewall-baseline.txt - || true)
if [ -n "$FIREWALL_DIFF" ]; then
REPORT+="AVISO: El firewall ha cambiado:\n${FIREWALL_DIFF}\n"
fi
# 5. Versiones de las imágenes 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: Las versiones de los contenedores han cambiado:\n${IMAGE_DIFF}\n"
fi
# 6. Espacio en 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 al ${DISK_USAGE}%\n"
fi
# 7. Estado de los backups
LAST_BACKUP=$(ssh deploy@${PROD_HOST} "ls -t /opt/backups/*.gpg 2>/dev/null | head -1")
if [ -z "$LAST_BACKUP" ]; then
REPORT+="CRITICO: No se encontró ningún backup\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: El último backup tiene ${BACKUP_AGE} horas de antigüedad\n"
fi
fi
# 8. Verificación 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} tablas sin RLS\n"
fi
# Resultado
echo "=== Security Check $(date) ==="
if [ -n "$REPORT" ]; then
echo -e "$REPORT"
else
echo "Todas las verificaciones superadas"
fi
# En caso de hallazgos críticos: enviar alerta
if [ "$CRITICAL" -eq 1 ]; then
echo -e "$REPORT" | mail -s "CRITICO: Security Check $(date)" ops@example.com
fi
# Cron en el audit-runner
0 7 * * * /opt/audit/scripts/security-check.sh >> /var/log/security-check.log 2>&1
Condición verificable
# ¿Se ejecutó el script de verificación hoy?
grep "$(date +%Y-%m-%d)" /var/log/security-check.log | tail -1
# Expectativa: entrada del día presente
# ¿Resultado?
grep "Todas las verificaciones superadas\|CRITICO\|AVISO" /var/log/security-check.log | tail -5
D4 - Claude Code como capa de análisis contextual
Claude Code analiza los resultados de las verificaciones deterministas y reconoce correlaciones que los scripts no pueden ver.
Arquitectura
Verificaciones diarias (D3)
|
+-- Hallazgos deterministas
| (puertos abiertos, drift del firewall, backups faltantes)
|
+---> Revisión semanal con Claude Code
|
+-- Archivos de config (docker-compose.yml, Caddyfile, .env.example)
+-- Logs de Security Check de los últimos 7 días
+-- Git Diff de los cambios de infraestructura
+-- Exportación de RLS Policies
|
+---> Informe priorizado
|
+---> Decisión DevOps (humano)
Script concreto
#!/bin/bash
# scripts/claude-review-prep.sh (se ejecuta en 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 revisión de seguridad semanal
## Contexto
Stack Supabase self-hosted en Hetzner Cloud.
Arquitectura: Reverse Proxy -> Kong -> Supabase Services -> PostgreSQL
Sistema de auditoría en servidor separado.
HEADER
# Logs de Security Check de los últimos 7 días
echo -e "\n## Resultados de Security Check (últimos 7 días)\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
grep -A 20 "Security Check" /var/log/security-check.log | \
tail -100 >> "$OUTPUT"
echo '```' >> "$OUTPUT"
# Configuración actual de Docker Compose (sin Secrets)
echo -e "\n## docker-compose.yml actual\n" >> "$OUTPUT"
echo '```yaml' >> "$OUTPUT"
ssh deploy@10.0.1.10 "cat /opt/supabase/docker-compose.yml" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
# Git Diff de la última semana
echo -e "\n## Cambios de infraestructura (últimos 7 días)\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"
# Estado de RLS
echo -e "\n## Estado de 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"
# Versiones de los contenedores
echo -e "\n## Versiones de los contenedores\n" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
ssh deploy@10.0.1.10 "docker compose images --format '{{.Repository}}:{{.Tag}}'" >> "$OUTPUT"
echo '```' >> "$OUTPUT"
echo "Input para revisión creado: $OUTPUT"
Lo que Claude Code no hace
Claude NO ejecuta cambios automáticos en producción.
Claude NO despliega.
Claude NO rota secrets.
Claude NO tiene acceso directo al servidor de producción.
Claude analiza los datos que se le proporcionan
y crea informes para decisiones humanas.
Checklist de despliegue
Antes de la primera puesta en marcha y tras cambios importantes:
Parte A - Infraestructura
[ ] Dos servidores separados (prod + audit)
[ ] Red privada configurada y probada
[ ] Cloud Firewall activo (solo 443, SSH desde IP de Admin)
[ ] Host Firewall activo (iptables)
[ ] Baseline del firewall guardada
[ ] SSH: solo keys, sin root, sin contraseña
[ ] Reverse Proxy configurado (Caddy/Nginx)
[ ] TLS activo con renovación automática
[ ] Cabeceras de seguridad configuradas (HSTS, CSP, X-Frame-Options)
[ ] Proxy WebSocket configurado para Realtime
Parte B - Servicios de Supabase
Secrets (B0)
[ ] Todos los secrets generados (sin valores por defecto)
[ ] JWT_SECRET al menos 32 caracteres
[ ] POSTGRES_PASSWORD al menos 24 caracteres
[ ] DASHBOARD_PASSWORD al menos 16 caracteres
[ ] SECRET_KEY_BASE al menos 64 caracteres
[ ] MINIO_ROOT_PASSWORD al menos 8 caracteres (si se usa MinIO)
[ ] DB_ENC_KEY cambiado (no "supabaserealtime")
PostgreSQL (B1)
[ ] Puerto solo en interfaz interna (10.0.1.10:5432)
[ ] Init-Scripts sin modificar (roles.sql, jwt.sql etc.)
[ ] Roles creados correctamente (anon no es superuser)
Kong (B2)
[ ] Puerto solo en localhost (127.0.0.1:8000)
[ ] JWT Validation activa en todas las rutas API
[ ] Contraseña del dashboard fuerte
GoTrue (B3)
[ ] JWT_EXP máximo 3600
[ ] AUTOCONFIRM false
[ ] SMTP configurado y probado
[ ] SITE_URL y API_EXTERNAL_URL correctos
[ ] Refresh Token Rotation activo
[ ] DISABLE_SIGNUP configurado si la app es cerrada
PostgREST (B4)
[ ] Usa rol authenticator (no postgres)
[ ] DB_SCHEMAS definido explícitamente
Realtime (B5)
[ ] DB_ENC_KEY cambiado
[ ] SECRET_KEY_BASE al menos 64 caracteres
Storage / MinIO (B6)
[ ] MinIO no accesible desde el exterior
[ ] Credenciales por defecto de MinIO cambiadas
[ ] Permisos del volumen de Storage correctos
Servicios internos (B7)
[ ] Puerto de Analytics solo localhost
[ ] Docker Socket de Vector en Read-Only
[ ] Puerto de Supavisor solo interno
[ ] Tokens de Logflare no por defecto
Studio (B8)
[ ] Desactivado en producción O solo mediante SSH Tunnel
[ ] Contraseña del dashboard fuerte
Parte C - Configuración del stack
[ ] docker-compose.yml versionado en Git
[ ] Todas las versiones de imágenes fijadas (sin :latest)
[ ] .env no está en Git
[ ] Permisos del archivo .env 600
[ ] .env.example en Git como plantilla
[ ] Sin contraseñas por defecto
[ ] RLS activo en todas las tablas public
[ ] Sin tablas sin Policies (excepto las intencionadas)
[ ] Sin Policies demasiado abiertas (qual = 'true')
Parte D - Operación y monitorización
[ ] Job de backup diario activo
[ ] Backups cifrados
[ ] Backups almacenados externamente (audit-runner o S3)
[ ] Prueba de restore realizada al menos una vez
[ ] Estrategia de retención configurada
[ ] Job diario de Security Check en audit-runner
[ ] Alertas ante hallazgos críticos
[ ] Revisión semanal con Claude Code configurada
Parte E - Actualizaciones y mantenimiento
[ ] Unattended Upgrades instalado y activo
[ ] Intervalo de actualización automática configurado a diario
[ ] Imágenes de Supabase fijadas (sin :latest)
[ ] Ninguna imagen de Supabase con más de 90 días
[ ] Commit de actualización dentro de los últimos 45 días
[ ] Script auto-patch para PostgreSQL minor patches activo
[ ] Cron auto-patch DESPUÉS del cron de backup (03:00 después de 02:00)
[ ] Monitor de releases de seguridad en audit-runner activo (diario)
[ ] Trivy instalado en audit-runner
[ ] Cron de verificación de mantenimiento en audit-runner (semanal lunes)
[ ] Certificado TLS válido al menos 14 días
[ ] Uso de disco por debajo del 85%
Parte E - Actualizaciones y mantenimiento
Un stack self-hosted que no se actualiza regularmente acumula vulnerabilidades de seguridad. CVEs sin parchear en PostgreSQL, Kong o GoTrue son vectores de ataque reales. Al mismo tiempo, las actualizaciones pueden introducir breaking changes que dejen el stack fuera de servicio.
Por eso el proceso de actualización necesita reglas claras: qué se actualiza y cuándo, cómo se prueba y cómo se garantiza que nada se olvide.
E1 - Tres niveles de actualización
El stack tiene tres niveles de actualización independientes con ritmos y riesgos diferentes.
Nivel 1: OS-Level (Ubuntu)
│ Qué: Kernel, System-Packages, OpenSSL, Docker Engine
│ Ritmo: Parches de seguridad semanales, actualización completa mensual
│ Riesgo: Bajo (apt upgrade es estable)
│ Método: apt update && apt upgrade
│
Nivel 2: Servicios Supabase (Docker Images)
│ Qué: PostgreSQL, Kong, GoTrue, PostgREST, Realtime, Storage, etc.
│ Ritmo: Mensual (ciclo de releases de Supabase)
│ Riesgo: Medio a alto (breaking changes entre versiones)
│ Método: Cambiar image tags en docker-compose.yml, pull, restart
│
Nivel 3: Reverse Proxy y herramientas
│ Qué: Caddy, iptables, GPG, nmap, jq
│ Ritmo: Ante Security Advisories o trimestralmente
│ Riesgo: Bajo
│ Método: apt upgrade (Caddy a través de su propio repo)
Condición verificable:
# OS: ¿Cuándo fue el último apt upgrade?
stat -c %y /var/cache/apt/pkgcache.bin
# Expectativa: menos de 7 días de antigüedad
# Supabase: ¿Qué versiones de imagen están en ejecución?
cd /opt/supabase && docker compose images --format '{{.Repository}}:{{.Tag}}'
# Caddy Version
caddy version
Escenario de fallo: Un PostgreSQL sin parchear con una vulnerabilidad conocida de Remote Code Execution (como CVE-2023-5869) puede ser explotado por un atacante, incluso si RLS está configurado correctamente. Un Kong desactualizado con una vulnerabilidad conocida de Auth-Bypass puede eludir la validación JWT.
E2 - Actualizaciones a nivel de OS
Semanal: Parches de seguridad (automático)
# Instalar y configurar Unattended Upgrades
sudo apt install -y unattended-upgrades
# Configuración: solo 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";
};
// Reinicio automático si es necesario (p. ej. Kernel Update)
// Solo activar si se acepta que el servidor
// reinicie brevemente a las 4:00 de la madrugada
Unattended-Upgrade::Automatic-Reboot "false";
// Notificación por email en caso de actualizaciones
Unattended-Upgrade::Mail "ops@example.com";
Unattended-Upgrade::MailReport "on-change";
EOF
# Activar actualizaciones 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
Mensual: Actualización completa del sistema (manual, con verificación)
# Primero verificar qué se va a actualizar
apt list --upgradable
# Luego actualizar
sudo apt update && sudo apt upgrade -y
# Actualización de Docker Engine (por separado, a través del repo de Docker)
sudo apt install --only-upgrade docker-ce docker-ce-cli containerd.io
# Después de actualizaciones del kernel: ¿reinicio necesario?
if [ -f /var/run/reboot-required ]; then
echo "REBOOT ERFORDERLICH"
fi
Condición verificable:
# ¿Unattended Upgrades activo?
systemctl is-active unattended-upgrades
# Últimas actualizaciones automáticas
cat /var/log/unattended-upgrades/unattended-upgrades.log | tail -20
# ¿Actualizaciones de seguridad pendientes?
apt list --upgradable 2>/dev/null | grep -i security | wc -l
# Expectativa: 0
E3 - Actualizaciones de servicios Supabase
Supabase publica aproximadamente cada mes nuevas Docker Images. El proceso de actualización debe realizarse de forma controlada porque son posibles breaking changes entre versiones.
Workflow:
1. Leer Release Notes (github.com/supabase/supabase/releases)
2. Registrar nuevos image tags en docker-compose.yml
3. Probar en Staging/Test (o: Backup + plan de rollback)
4. Crear backup
5. docker compose pull
6. docker compose down && docker compose up -d
7. Verificar Health Checks
8. Actualizar baselines
Verificar versiones actuales vs. disponibles:
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"
Proceso de actualización seguro:
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 cuando algo falla:
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
Condición verificable:
# 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
Escenario de fallo: Supabase GoTrue v2.170.0 tuvo un cambio en el manejo de Refresh Tokens que rompió clientes antiguos. Sin leer previamente las Release Notes y sin backup, esto habría sido una caída del servicio. Las actualizaciones de versiones mayores de PostgreSQL (p. ej. 15 -> 16) requieren un ciclo pg_dump/pg_restore, un simple cambio de image tag no es suficiente.
E3b - Parcheado automático (Nivel 1)
No todas las actualizaciones requieren intervención humana. Los PostgreSQL Minor Patches y las actualizaciones de Caddy son de bajo riesgo y pueden aplicarse automáticamente por la noche.
Modelo de dos niveles:
NIVEL 1 - AUTOMÁTICO (auto-patch.sh, diario 03:00):
OS Security Patches (unattended-upgrades)
PostgreSQL Minor Patches (15.8.1.x -> 15.8.1.y)
Caddy Updates
-> Email informativo tras parcheado exitoso
-> Email de alarma ante fallo en Health Check
-> Rollback automático en caso de error
NIVEL 2 - MANUAL (/supabase-update, dentro de las 24h tras alerta):
GoTrue/Auth (posibles breaking changes)
PostgREST (el comportamiento de queries puede cambiar)
Kong (el enrutamiento puede cambiar)
Realtime, Storage, Supavisor
PostgreSQL MAJOR (15 -> 16, requiere pg_dump/pg_restore)
El script auto-patch.sh se ejecuta diariamente a las 03:00 (después del backup a las 02:00) y:
- Verifica que exista un backup actual (aborta si no)
- Guarda el estado actual (docker-compose.yml, versiones de imágenes)
- Comprueba si hay disponible una nueva imagen minor de PostgreSQL
- La aplica y ejecuta un Health Check
- En caso de error: rollback automático a la versión anterior
- Envía un email informativo (éxito) o de alarma (error)
Condición verificable:
# 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
Planificación cron (servidor prod):
02:00 diario -> Backup (DB + Storage + externo)
03:00 diario -> Auto-Patch (PostgreSQL Minor + Caddy)
04:00 mensual -> Test de restore
E4 - Calendario de actualizaciones y responsabilidades
Semanal (automático):
[ ] OS Security Patches (unattended-upgrades)
[ ] Audit-Runner verifica que los parches se hayan aplicado
Mensual (manual, planificado):
[ ] Revisar Supabase Release Notes
[ ] Evaluar nuevos image tags
[ ] Crear backup
[ ] Ejecutar actualización
[ ] Health Checks
[ ] Actualizar baselines
Trimestral (review):
[ ] Verificar versión de Caddy
[ ] Verificar versión de Docker Engine
[ ] Verificar versión de Node.js (para Claude Code en audit-runner)
[ ] Evaluar versión mayor de PostgreSQL
[ ] ¿Toda la toolchain al día?
Ante Security Advisories (inmediato):
[ ] ¿El CVE afecta a nuestro stack?
[ ] Identificar imagen/paquete afectado
[ ] ¿Parche disponible?
[ ] Ejecutar actualización de emergencia
Condición verificable:
# 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 seguridad (diario)
El mayor punto ciego del self-hosting no es la configuración inicial, sino el no detectar los parches de seguridad a tiempo. Cuando Supabase GoTrue publica un fix de Auth-Bypass, el equipo debe actuar en 24 horas, no al cabo de una semana.
En el audit-runner se ejecuta diariamente un script que comprueba los GitHub Releases de todos los componentes de Supabase y alerta inmediatamente por email ante releases de seguridad.
Componentes verificados:
supabase/auth (GoTrue) -> parches de seguridad frecuentes
PostgREST/postgrest -> API-Layer
supabase/realtime -> WebSocket
supabase/storage-api -> File Storage
Kong/kong -> API Gateway
supabase/edge-runtime -> Edge Functions
supabase/postgres -> Datenbank Image
supabase/supavisor -> Connection Pooler
moby/moby (Docker Engine) -> Container Runtime
Tres capas de verificación:
Capa 1: GitHub Releases
-> ¿Hay una nueva versión?
-> ¿Las Release Notes contienen "security"/"CVE"?
Capa 2: Trivy Container Scan
-> Escanea cada Docker Image en ejecución contra NVD/GitHub Advisories
-> Encuentra CVEs en todas las dependencias (OS-Packages, Libraries)
Capa 3: OSV API
-> Verifica vulnerabilidades a nivel de aplicación para GoTrue, PostgREST, etc.
-> Complementa Trivy con CVEs específicos de paquetes
Cómo funciona:
Diario 07:00 (audit-runner Cron)
│
├── Obtener versiones actuales del servidor prod
├── GitHub API: Verificar últimos releases para cada componente
├── Escanear Release Notes buscando "security", "CVE", "vulnerability"
│
├── ¿Release de seguridad encontrado?
│ -> Email INMEDIATO a ops@
│ -> "Acción requerida en 24h"
│
└── ¿Release normal encontrado?
-> Resumen semanal (lunes)
Mecanismo de caché: El script recuerda los releases ya notificados para que no llegue el mismo email cada día. Solo se vuelve a alertar cuando hay un NUEVO release.
Condición verificable:
# 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"
Escenario de fallo: En enero de 2024 se publicó CVE-2023-5869 para PostgreSQL (Remote Code Execution). Quien no tuviese un monitor de releases y solo revisase actualizaciones mensualmente, estuvo vulnerable durante 3-4 semanas. Con el monitor diario, el email habría llegado al día siguiente de la publicación.
E6 - Audit-Runner como vigilante de actualizaciones
El Audit-Runner supervisa si las actualizaciones se están realizando e informa al equipo cuando algo está vencido.
En el audit-runner se ejecuta un script semanal que verifica lo siguiente:
#!/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 en el 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
Cuándo notifica Claude:
Claude Code en el audit-runner envía notificaciones en tres casos:
INMEDIATO (email a ops@):
- Unattended Upgrades no activo
- Certificado TLS < 14 días
- Actualización de seguridad pendiente y > 3 días de antigüedad
SEMANAL (informe de mantenimiento):
- Reinicio necesario
- apt update vencido
- Imágenes Supabase > 60 días de antigüedad
MENSUAL (recordatorio de actualización):
- Release Notes de Supabase no revisadas (sin commit de update > 45 días)
- Review trimestral pendiente
Conclusión
Alojar Supabase por cuenta propia es relativamente sencillo. Operar Supabase de forma segura requiere reglas de arquitectura claras y control automatizado.
Este runbook separa entre decisiones de infraestructura (Parte A), arquitectura y aseguramiento de servicios (Parte B), configuración del stack (Parte C), monitorización continua (Parte D) y procesos de actualización (Parte E). La combinación de verificaciones deterministas y análisis contextual con Claude Code cubre tanto patrones conocidos como riesgos inesperados.
Quien aplica estos principios desde el inicio construye una arquitectura Cert-Ready by Design y se ahorra rondas de auditoría posteriores.
Recordatorio: Las configuraciones específicas de Hetzner (vSwitch, Cloud Firewall, nombres de interfaz) se pueden trasladar directamente a proveedores españoles y europeos como Arsys/IONOS (ES), OVH (ES), Stackscale (ES) y Acens (ES). Los principios de arquitectura son independientes del proveedor.
Descarga de la lista de verificación
Prompt preparado para Claude Code. Suba el archivo a su servidor e inicie Claude Code en el directorio del proyecto de su stack Supabase. Claude Code verificará automáticamente todos los puntos de seguridad de este runbook e informará APROBADO, ADVERTENCIA o CRÍTICO.
claude -p "$(cat claude-check-artikel-1-supabase-es.md)" --allowedTools Read,Grep,Glob,Bash
Descargar checklistÍndice de la serie
Este artículo forma parte de nuestra serie DevOps para app stacks self-hosted.
- Supabase Self-Hosting Runbook - este artículo
- Next.js sobre Supabase de forma segura
- Supabase Edge Functions de forma segura
- Trigger.dev Background Jobs en producción segura
- Claude Code como control de seguridad en el workflow DevOps
- Security Baseline para todo el stack
En el próximo artículo mostraremos cómo operar Next.js de forma segura sobre Supabase, evitando los errores típicos en Server Actions, Auth Handling y accesos a la API.

Bert Gogolin
Director General, Gosign
AI Governance Briefing
IA empresarial, regulación e infraestructura - una vez al mes, directamente de mi parte.