Ir al contenido
Infraestructura & Tecnología

Supabase Self-Hosting Runbook: arquitectura segura

Supabase Self-Hosting Runbook: configuracion Hetzner, Docker Compose, arquitectura de servicios, secrets, RLS y backups.

Mansoor Ahmed
Mansoor Ahmed
Head of Engineering 30 min de lectura

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.

  1. Supabase Self-Hosting Runbook - este artículo
  2. Next.js sobre Supabase de forma segura
  3. Supabase Edge Functions de forma segura
  4. Trigger.dev Background Jobs en producción segura
  5. Claude Code como control de seguridad en el workflow DevOps
  6. 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

ServicioPuertoFunciónConfiguración crítica de seguridad
PostgreSQL5432Almacenamiento de datosRLS en todas las tablas, listen_address solo interno
PostgREST3000REST APIAplicación automática de RLS, acceso basado en roles
GoTrue9999AutenticaciónJWT expiry, confirmación por email, refresh token rotation
Kong8000API GatewayRate limiting, enrutamiento, validación JWT
Realtime4000WebSocketAutorización de canales, límites de conexión
Storage5000Object StoragePolíticas de buckets, límites de tamaño de archivo
Studio3000Panel de administraciónAcceso 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 (normalmente ens10). 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:

  1. Verifica que exista un backup actual (aborta si no)
  2. Guarda el estado actual (docker-compose.yml, versiones de imágenes)
  3. Comprueba si hay disponible una nueva imagen minor de PostgreSQL
  4. La aplica y ejecuta un Health Check
  5. En caso de error: rollback automático a la versión anterior
  6. 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.

  1. Supabase Self-Hosting Runbook - este artículo
  2. Next.js sobre Supabase de forma segura
  3. Supabase Edge Functions de forma segura
  4. Trigger.dev Background Jobs en producción segura
  5. Claude Code como control de seguridad en el workflow DevOps
  6. 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

Bert Gogolin

Director General, Gosign

AI Governance Briefing

IA empresarial, regulación e infraestructura - una vez al mes, directamente de mi parte.

Sin spam. Cancelable en cualquier momento. Política de privacidad

Supabase Self-Hosting DevOps Security PostgreSQL
Compartir este artículo

Preguntas frecuentes

¿Qué componentes tiene el stack de Supabase Self-Hosting?

El stack de Supabase se compone de PostgreSQL, PostgREST API, GoTrue Auth, servidor Realtime, Storage, Kong API Gateway y Supabase Studio. Opcionalmente se añaden Edge Functions. Con ello se opera una plataforma backend completa, no solo una base de datos.

¿Por qué se necesitan dos servidores para Supabase Self-Hosting?

La separación entre sistema de producción y sistema de auditoría impide que un servidor comprometido pueda manipular simultáneamente sus propias verificaciones de seguridad. El sistema de auditoría permanece independiente y puede detectar de forma fiable la deriva de configuración.

¿Es este artículo parte de una serie?

Sí. Este runbook es la parte 1 de una serie DevOps de seis partes para app stacks self-hosted. La serie abarca Supabase, Next.js, Edge Functions, Trigger.dev, Claude Code como control de seguridad y una Security Baseline.