Reemplacé Supabase con Convex y lo Auto-Hospedé Gratis. Aquí Tienes la Guía Completa.

9 min read

12 contenedores Docker para Supabase, uno para Convex — y cero límites de ancho de banda cuando lo alojas tú mismo. Manual de migración completo incluido.


Estaba terminando una sesión de código, listo para tomarme una piña colada en la terraza. Entonces: una alerta de Convex. Límite de uso casi alcanzado.

Se me quitaron las ganas de alcohol inmediatamente.

Primera reacción: "No puede ser. Es día 3. La cuota se reinició hace dos días." Segunda: averiguar qué proyecto era el culpable. Tercera: revisar las consultas. Y ahí estaba — db.query("metrics").collect() por todas partes. Escaneo completo de tabla en cada mutación. 12,500 filas. Cuatro consultas reactivas ejecutándose en bucle cada vez que cambiaba un solo documento.

Yo escribí ese código. Bueno, Claude Code lo hizo. Es lo mismo.

Esto había estado rondando mi cabeza desde hacía tiempo — alojar Convex por mi cuenta. Ya había verificado que era posible. Solo no sabía cómo. Esa noche, tenía mi respuesta del por qué tenía que pasar.


TL;DR: El auto-alojamiento de Supabase requiere 12 contenedores Docker y 8 GB RAM mínimo (no viable en un VPS compartido). Convex es más limpio para auto-alojar: un contenedor, misma API, ancho de banda ilimitado. El único problema real es el enrutamiento multi-puerto del proxy inverso. Aquí está el manual completo, incluyendo el prompt de migración que previene el error de dos líneas que cometí.

Ingeniero técnico comparando soluciones de infraestructura en la nube con visualización de tira cómica
Cuando tu infraestructura pasa del caos al zen en un solo despliegue


Por Qué Dejé Supabase (La Versión Corta)

Esta historia en realidad empieza antes. Antes de Convex, antes de la alerta de ancho de banda — había un problema con Supabase.

Supabase cloud se estaba comiendo el margen que no estaba generando. Así que hice lo que cualquier dev razonable hace: miré el auto-alojamiento. Gratis, ¿verdad? Solo levantar los contenedores.

Un día después. Doce contenedores configurados, Traefik finalmente enrutando a los servicios correctos después de tres intentos fallidos, Kong peleando con cada variable de entorno que le lancé, la red inter-servicios haciendo lo suyo por razones que tomaron dos horas debuggear. Mi VPS estaba sudando. Pero funcionaba.

Entonces ejecuté docker ps.

supabase-db. supabase-studio. supabase-kong. supabase-auth. supabase-storage. supabase-realtime. supabase-meta. supabase-imgproxy. supabase-vector.

Doce contenedores. Para un backend. Kong solo puede consumir 2.5 GB bajo carga. Mínimo 8 GB RAM recomendado para producción. Tengo un VPS ejecutando otros cinco servicios. Eso es un no.

(Técnicamente puedes deshabilitar servicios no utilizados para reducir el uso de recursos. Pero entonces estás manteniendo un stack parcial y personalizado en un servidor que compartes con todo lo demás. Control total, dicen.)

Necesitaba algo más liviano. Así fue como encontré Convex, y descubrí un conjunto de ventajas que no esperaba. Si quieres el panorama completo, cubrí por qué Convex se convirtió en mi stack por defecto para construir SaaS con IA usando Claude Code. Versión corta: todo en TypeScript, consultas reactivas por defecto, auto-migraciones en deploy. Y Claude Code genera código que realmente compila a la primera porque está trabajando en un lenguaje a través de todo el stack en lugar de hacer malabares con migraciones SQL, Deno Edge Functions, y frontend TypeScript en contextos separados.

Una preocupación que tenía: vendor lock-in. Convex se volvió open source en febrero de 2025. Esa preocupación se evaporó.

Así que migré. Todo funcionó. Y entonces, tres meses después, casi alcancé el límite de 1 GB/día de ancho de banda.


La Llamada de Atención: 821 MB de 1 GB

El problema eran las consultas que había escrito. (Digo, ya sabes quién... 😉)

db.query("metrics").collect() es un escaneo completo de tabla. Cada fila, cada vez. En una tabla de 12,500 filas. Y porque las consultas reactivas de Convex se re-ejecutan cuando cualquier documento en la tabla consultada cambia, estas no se ejecutaban una vez. Se ejecutaban constantemente.

Cuatro de ellas: getTopContents escaneando todas las métricas para agrupar por contenido, crossPlatformMetrics agregando a través de plataformas, consultas de velocidad escaneando toda la tabla para sparklines, mediumOverview leyendo todas las distribuciones para conteos de seguidores.

El 3 de marzo, esa combinación consumió 821 MB de mi cuota diaria de 1 GB. Para la noche 💀

Antes de que digas "solo agrega índices" — lo sé. Llegué ahí. Pero la historia no termina ahí.


Las Soluciones Rápidas (Haz Esto Primero)

El movimiento 80/20 antes de siquiera pensar en auto-alojar: arreglar las consultas.

Agrega índices a tu schema:

metrics: defineTable({
  content_id: v.id("contents"),
  platform_name: v.string(),
  period: v.string(),
})
  .index("by_period", ["period"])
  .index("by_platform_period", ["platform_name", "period"])
  .index("by_content_id", ["content_id"]),

Filtra por período en lugar de escanear todo:

// Antes: lee cada fila en la tabla
const allMetrics = await ctx.db.query("metrics").collect();

// Después: lee solo el mes actual
const currentPeriod = new Date().toISOString().slice(0, 7); // "2026-03"
const metrics = await ctx.db
  .query("metrics")
  .withIndex("by_period", (q) => q.eq("period", currentPeriod))
  .collect();

Limita las consultas de sparkline a períodos recientes. Si estás dibujando un gráfico de 6 meses, construye la lista de strings de período que necesitas y consulta cada uno con un índice. No leas tres años de datos para seis puntos de datos.

Estos tres cambios redujeron mi ancho de banda aproximadamente 80%. Claude Code había escrito las consultas originales — rápido de generar, cero índices, funciona bien hasta que no. Las correcciones tomaron cerca de una hora.

Pero el techo de 1 GB sigue ahí. Cada nueva funcionalidad lo va carcomiendo. Cada artículo importado, cada actualización de dashboard en tiempo real. La cuota es estructural. La optimización compra tiempo.

El auto-alojamiento remueve el techo.


La Decisión: Auto-Alojar o Quedarse en la Nube?

Si ya tienes un servidor ejecutando Docker, auto-alojar Convex cuesta exactamente $0 extra.

Un contenedor. Mismo CLI, mismas librerías cliente, misma API. Apuntas tu app a una URL diferente y el resto de tu código no cambia. El backend es open source. Lo posees.

Esto tiene sentido si ya tienes Docker y un proxy inverso configurado, si te estás acercando a los límites de ancho de banda, o si quieres soberanía completa de datos en un proyecto donde los SLAs gestionados no importan.

No tiene sentido si necesitarías rentar un servidor solo para esto — el tier de nube es más barato de lo que piensas. Igual si usas Convex Auth o almacenamiento de archivos CDN, ninguno de los cuales existe en la versión auto-alojada aún. Y si debuggear Traefik a las 4 AM suena como una mala noche, quédate en la nube. Digo esto sin juicio.

Mi situación: ya tengo un servidor dedicado ejecutando Traefik, n8n, y varios otros servicios. Agregar un contenedor no cuesta nada. Las matemáticas eran obvias.


Por Qué Dos Subdominios

Convex auto-alojado expone dos puertos desde un solo contenedor. Puerto 3210 para el backend — consultas, mutaciones, WebSocket. Puerto 3211 para HTTP Actions — tus endpoints REST.

Dos puertos, dos subdominios:

convex.tudominio.com        → puerto 3210 (backend + WebSocket)
convex-http.tudominio.com   → puerto 3211 (HTTP actions)

Esta arquitectura es también la causa raíz del único problema real que tuve.


La Trampa de Traefik

Contexto rápido si nunca has usado Traefik: es un proxy inverso que se sienta frente a tus contenedores Docker y enruta el tráfico entrante al servicio correcto. Lo bueno es que se configura solo leyendo etiquetas en tus contenedores — no hay archivo de config que mantener. La mayoría de tutoriales de auto-alojamiento lo usan. Funciona genial.

Hasta que un contenedor expone dos puertos.

04:31:36 CET. Primer docker compose up -d. Logs de Traefik, inmediatamente:

ERR Router convex-actions cannot be linked automatically
  with multiple Services: ["convex-actions" "convex-backend"]
ERR Router convex-backend cannot be linked automatically
  with multiple Services: ["convex-actions" "convex-backend"]

El contenedor de Convex ni siquiera había terminado de iniciar. Traefik ya estaba rechazando el enrutamiento.

Mis etiquetas iniciales se veían así:

labels:
  - traefik.enable=true
  - traefik.http.routers.convex-backend.rule=Host(`convex.tudominio.com`)
  - traefik.http.routers.convex-backend.entrypoints=websecure
  - traefik.http.routers.convex-backend.tls.certresolver=letsencrypt
  - traefik.http.services.convex-backend.loadbalancer.server.port=3210
  - traefik.http.routers.convex-actions.rule=Host(`convex-http.tudominio.com`)
  - traefik.http.routers.convex-actions.entrypoints=websecure
  - traefik.http.routers.convex-actions.tls.certresolver=letsencrypt
  - traefik.http.services.convex-actions.loadbalancer.server.port=3211

Se ve bien. No lo está.

Cuando un solo contenedor Docker declara múltiples servicios Traefik, el auto-enlace se rompe. Traefik ve dos servicios y no sabe qué router mapea a cuál. En cada tutorial que hayas leído, hay un contenedor y un puerto — así que el mapeo es implícito y funciona. Con dos puertos, tienes que hacerlo explícito. Dos líneas:

labels:
  - traefik.enable=true
  - traefik.http.routers.convex-backend.rule=Host(`convex.tudominio.com`)
  - traefik.http.routers.convex-backend.entrypoints=websecure
  - traefik.http.routers.convex-backend.tls.certresolver=letsencrypt
  - traefik.http.routers.convex-backend.service=convex-backend       # ← esto
  - traefik.http.services.convex-backend.loadbalancer.server.port=3210
  - traefik.http.routers.convex-actions.rule=Host(`convex-http.tudominio.com`)
  - traefik.http.routers.convex-actions.entrypoints=websecure
  - traefik.http.routers.convex-actions.tls.certresolver=letsencrypt
  - traefik.http.routers.convex-actions.service=convex-actions       # ← esto
  - traefik.http.services.convex-actions.loadbalancer.server.port=3211

Dos minutos entre el error y la solución. La regla: cuando enrutas múltiples puertos desde un contenedor a través del proveedor Docker de Traefik, agrega etiquetas service= explícitas. Cada vez, sin excepciones.

Una nota sobre Cloudflare: Ejecuto Cloudflare frente a mis dominios con el proxy deshabilitado (solo DNS, nube gris) para estos subdominios. Let's Encrypt necesita alcanzar tu servidor directamente para emitir certificados. Si dejas la nube naranja activa, obtendrás un conflicto SSL que pasarás 20 minutos atribuyendo a la capa incorrecta — DNS, luego Traefik, luego Convex. Deshabilita el proxy para la migración.

Este servidor ya ejecuta varios servicios auto-alojados, incluyendo la infraestructura detrás de reconstruir mi setup de agente IA desde cero después de que una depreciación de API me forzó la mano. La filosofía es la misma: poseer el stack, remover los costos recurrentes, mantener la experiencia de desarrollador intacta.


El Prompt Que Previene Todo Esto

Si estás usando Claude Code para generar el docker-compose, dale este contexto por adelantado:

Auto-alojar Convex (ghcr.io/get-convex/convex-backend) detrás de Traefik.
Contenedor único, DOS puertos: 3210 (backend + WebSocket) y 3211 (HTTP actions).
Cada puerto necesita su propio subdominio con SSL.
Proveedor Docker de Traefik con Let's Encrypt ya configurado.

Cuatro líneas. Ese es el contexto que activa el patrón service= explícito. Sin él, cada LLM por defecto asume el tutorial estándar: un contenedor, un puerto, mapeo implícito. Funciona genial hasta que tienes dos puertos.

Igual alucinará otras cosas. Claude Code siempre lo hace. Pero al menos no esta específica.


La Migración (La Parte Que Apenas Hice)

Le pregunté a Claude Code: "¿Podemos migrar mi proyecto Convex cloud a la instancia auto-alojada?"

Apenas había abierto la pestaña de docs para averiguar por dónde empezar. Claude Code ya las había leído. "Verificando prerequisitos." Luego: conectándose al servidor vía SSH, leyendo el docker-compose existente, descargando la imagen, generando la clave admin. Yo observé. Ese era mi trabajo — observar una terminal hacer scroll más rápido de lo que podía leer mientras mi interno IA demolía una tarea para la que había presupuestado mentalmente dos horas.

Quince minutos después me dijo que actualizara dos registros DNS. Lo hice. Listo.

(Para que conste: sí supervisé. Muy atentamente. Desde mi silla.)

La secuencia, para los curiosos: con el contenedor ejecutándose y certificados SSL emitidos — Let's Encrypt toma cerca de 15 segundos, el primer curl retorna código de salida 60, no entres en pánico — la migración son cinco pasos.

Genera la clave admin:

docker compose exec backend ./generate_admin_key.sh

Empieza con convex-self-hosted|. Guárdala, nunca la commitees.

Crea .env.self-hosted en la raíz de tu proyecto:

CONVEX_SELF_HOSTED_URL=https://convex.tudominio.com
CONVEX_SELF_HOSTED_ADMIN_KEY=convex-self-hosted|tu-clave-aqui

Cada comando npx convex necesita --env-file .env.self-hosted de ahora en adelante. Sin él, el CLI silenciosamente apunta a la nube. Fácil de olvidar, peligroso de perder.

Exporta de la nube primero:

npx convex export --path convex-backup.zip

Este es tu rollback. Hazlo antes que cualquier otra cosa.

Despliega schema, luego importa — en ese orden:

npx convex deploy --env-file .env.self-hosted --yes
npx convex import --env-file .env.self-hosted convex-backup.zip

Schema primero crea las tablas e índices. Import necesita que existan. Intercambia el orden y el import falla.

Establece variables de entorno:

npx convex env list                                                    # lista vars de nube primero
npx convex env set --env-file .env.self-hosted TU_CLAVE "valor"

Dos cosas que atrapan a la gente: las variables NEXT_PUBLIC_* en Next.js se hornean en tiempo de build. Cambiarlas en Vercel no hace nada hasta que redespliegues. El dashboard se ve actualizado. La app sigue hablando con la nube. Redespliega.

Y si tienes consumidores de HTTP Actions — un sitio estático, webhooks externos — necesitan la URL de HTTP Actions actualizada por separado. Backend funcionando no significa que actions funcionen. Puerto diferente, subdominio diferente, prueba ambos.


Backups: Ahora Es Tu Problema

#!/bin/bash
BACKUP_DIR="/ruta/a/backups"
DATE=$(date +%Y%m%d-%H%M)
VOLUME_NAME="tu-proyecto_convex_data"  # verifica: docker volume ls

mkdir -p "$BACKUP_DIR"
docker run --rm \
  -v "${VOLUME_NAME}:/data:ro" \
  -v "${BACKUP_DIR}:/backup" \
  alpine tar czf "/backup/convex-${DATE}.tar.gz" -C / data

find "$BACKUP_DIR" -name "convex-*.tar.gz" -mtime +30 -delete

Agregar a cron, diario a las 3 AM. El nombre del volumen incluye tu directorio de proyecto como prefijo — verifica con docker volume ls si no estás seguro.

Mantén tu instancia Convex Cloud viva por 48 horas después de la migración. Tus datos de nube nunca fueron modificados. Rollback es un cambio de variable de entorno y un redeploy. Tres minutos.


Resultados

Latencia: auto-alojado en un servidor OVH en Francia ejecuta a cerca de 461ms ida y vuelta versus 413ms en Convex Cloud US. Insignificante. Usuarios EU en un servidor EU podrían ver mejor latencia que el default de nube basado en US.

Ansiedad de ancho de banda: desaparecida. Lancé dos nuevas funcionalidades de dashboard la semana después sin pensar ni una vez en cuota.

La comparación con Supabase se mantiene a nivel de infraestructura también: un contenedor Convex en idle usa menos de 200 MB RAM. Supabase auto-alojado empieza en 12 contenedores y 8 GB recomendado. Para un proyecto secundario en un VPS compartido, esa diferencia lo es todo.

Cerca de medianoche, cerré la terminal. La piña colada seguía en la mesa, de alguna manera.

Salud.


Checklist

□ Crear 2 registros DNS A apuntando a la IP de tu servidor
□ Deshabilitar proxy Cloudflare (nube gris) para ambos subdominios
□ Escribir docker-compose.yml con etiquetas service= explícitas en ambos routers
□ docker compose up -d
□ Esperar ~15s para certs SSL (código de salida 60 en primer curl = normal)
□ Generar clave admin
□ Crear .env.self-hosted
□ Exportar de nube: npx convex export --path backup.zip
□ Desplegar schema: npx convex deploy --env-file .env.self-hosted --yes
□ Importar datos: npx convex import --env-file .env.self-hosted backup.zip
□ Establecer vars env: npx convex env set --env-file .env.self-hosted CLAVE valor
□ Probar consultas backend
□ Probar HTTP actions por separado (subdominio diferente)
□ Actualizar NEXT_PUBLIC_CONVEX_URL + redesplegar frontend
□ Actualizar cualquier consumidor de HTTP Actions
□ Configurar cron de backup
□ Mantener nube viva 48h como rollback
□ Verificar que backup ejecute al día siguiente

Fuentes


Escribo sobre lo que realmente pasa cuando construyes proyectos de producción con herramientas IA — no el highlight reel. Si eso suena útil, suscríbete.


Esa imagen de portada es 100% IA. Soy el tipo que debuggea etiquetas de Traefik a las 4 AM — diseñador no soy.



Descubre cómo migré de Supabase a Convex con un solo contenedor Docker, eliminando límites de ancho de banda y optimizando mi infraestructura de SaaS.

Recibe el kit de bienvenida