Ir al contenido

Puesta en producción

ADR-0009 explica por qué cada pieza va donde va. Esta página es el cómo, en orden.

Pieza Dónde corre Se despliega con
web Vercel push a main
api Railway push a main
Postgres Supabase migraciones
Correo Resend —
DNS Cloudflare (dominio en Namecheap) —

No. Supabase corre Postgres, y para código ofrece Edge Functions, que son Deno y serverless. api es Node con Fastify. Son dos incompatibilidades distintas y cada una alcanza sola:

Lo que ofrece Supabase Lo que necesita api
Runtime Deno Node 22
Ciclo de vida una función por petición un proceso que sigue vivo

Lo segundo es lo que no se negocia: api mantiene un pool de diez conexiones y toma pg_advisory_xact_lock dentro de transacciones. Las dos cosas asumen un proceso que sobrevive entre peticiones. En funciones, cada invocación abriría su propio pool y la base se quedaría sin conexiones antes que sin trabajo.

Supabase hace de base de datos. api se despliega aparte.

Cualquier plataforma que corra el Dockerfile que api ya tiene y lo mantenga arriba:

Servicio A favor En contra
Railway Detecta el Dockerfile solo, deploy en cada push, variables en el panel. Lo más corto de acá a producción Cuesta más por hora que Fly
Fly.io Más barato al escalar, elección de región Pide fly.toml y su CLI: más pasos antes del primer deploy
Render Similar a Railway Los planes gratuitos duermen el servicio

Para Aquazaku conviene Railway. No por ser mejor plataforma, sino porque el cuello de botella no es el costo ni la latencia: es cuántos pasos hay entre un push y la planta usando el sistema. Con ocho personas y una sola región, la región configurable de Fly no compra nada y sí cuesta un archivo más que mantener.

Si en un año el costo empieza a doler, mudarse es cambiar de plataforma el mismo Dockerfile.


Las cinco con el correo de Aquazaku, no con el personal. No es formalidad: el día que alguien más administre la planta, una cuenta a nombre de una persona es un rehén.

Si la contraseña de Postgres pasó por un chat, un ticket o un mensaje, ya no es secreta. Se rota en el panel de Supabase antes de cargar nada.


Sección titulada «3. El reparto de dominios, y dónde vive la cookie»
Nombre Apunta a Qué es
aquazaku.com — el sitio oficial, público
app.aquazaku.com Vercel la aplicación
api.aquazaku.com Railway el servidor, que nadie visita
Sección titulada «COOKIE_DOMAIN va al subdominio de la app, NO al padre»

Es la parte que más fácil se hace mal, y una versión anterior de esta página la recomendaba mal.

Valor Quién recibe la sesión
app.aquazaku.com ✅ solo la aplicación
.aquazaku.com ⚠️ la app, el sitio oficial, y todo subdominio futuro
api.aquazaku.com ❌ el navegador la descarta

El reflejo es poner el padre para que api también la reciba. No hace falta, y es donde ADR-0002 se paga solo:

web/src/lib/api-server.ts
outgoingHeaders.set('Cookie', cookieStore.toString())

web lee la cookie que el navegador le mandó a él y se la reenvía a api como un header común. Ese salto es servidor a servidor: las reglas de dominio del navegador no aplican ahí. La cookie nunca necesita ser válida para api.aquazaku.com, porque el navegador jamás le habla a api.

Con el padre, en cambio, la sesión viaja en cada petición al sitio oficial —y a cualquier cosa que se cuelgue de un subdominio en el futuro—. Es httpOnly, así que ningún JavaScript la lee; pero el servidor de ese otro sitio la recibe entera.


4.1 — El rol de la aplicación va ANTES de migrar

Sección titulada «4.1 — El rol de la aplicación va ANTES de migrar»

En Supabase el rol dueño ya existe y se llama postgres. El que falta es el de la aplicación.

En el SQL Editor de Supabase:

CREATE ROLE aquazaku_app LOGIN PASSWORD 'una-contraseña-fuerte-y-distinta';

Distinta de la de postgres. Son dos roles justamente para que comprometer uno no entregue el otro.

Del panel de Supabase, las del pooler. El panel ofrece las dos con el usuario postgres: la de la aplicación hay que cambiarla a mano.

Variable Modo Puerto Rol Por qué ese
DATABASE_URL transacción 6543 aquazaku_app pg_advisory_xact_lock se libera al cerrar la transacción
DATABASE_MIGRATION_URL sesión 5432 postgres DDL y REVOKE, que el modo transacción no sostiene

Desde tu máquina, con DATABASE_MIGRATION_URL apuntando a Supabase:

Ventana de terminal
pnpm db:migrate

Que las migraciones corran no prueba que el candado exista. Conectate con la cadena de DATABASE_URL —la de aquazaku_app— y hacé estas tres:

-- 1. ¿Con qué rol estoy conectado? Tiene que decir aquazaku_app.
SELECT current_user;
-- 2. Un libro de movimientos: tiene que responder «permission denied».
UPDATE movimientos_stock SET cantidad = 0 WHERE id IS NOT NULL;
-- 3. La bitácora: tiene que responder «bitácora de auditoría es de solo inserción».
UPDATE audit_log SET accion = 'otra cosa' WHERE id IS NOT NULL;
Qué Cómo ¿Frena a postgres?
audit_log nunca recibe UPDATE/DELETE, y tres triggers El trigger sí; pero el dueño puede desactivarlo
10 libros de movimientos 20 REVOKE en 9 migraciones No. El REVOKE no aplica al dueño

Por eso las dos capas se sostienen entre sí: los triggers frenan la mutación, y los permisos impiden quitar los triggers. Conectar la aplicación como postgres elimina la segunda, y con ella la primera queda a un ALTER TABLE de distancia.

Crea el catálogo de roles, un administrador y los tres productos reales. No carga clientes ni ventas de prueba: un cliente inventado en producción sobrevive años y aparece en un reporte.

Ventana de terminal
SEED_CONFIRM=yes \
SEED_ADMIN_EMAIL=vos@tudominio.com \
SEED_ADMIN_PASSWORD='una-contraseña-larga' \
pnpm db:seed

SEED_CONFIRM=yes es obligatorio en producción a propósito. Es idempotente: si ya hay un admin activo, no hace nada.

Los productos quedan desactivados y sin precio. No se pueden vender hasta que un admin les cargue el precio real — sembrar un precio inventado sería peor.


5. Probá el respaldo antes de tener qué perder

Sección titulada «5. Probá el respaldo antes de tener qué perder»
Ventana de terminal
# pg_dump tiene que ser 17: Supabase corre 17.6 y pg_dump se niega a volcar
# un servidor más nuevo que él. Homebrew no lo enlaza solo.
export PATH="$(brew --prefix postgresql@17)/bin:$PATH"
pnpm db:respaldo

Conviene correrlo ahora, con la base recién sembrada. Un respaldo roto descubierto hoy cuesta cero; descubierto en marzo cuesta el año.

El script verifica el volcado antes de comprimirlo: el marcador de cierre de pg_dump, las tablas que declara el esquema, y que haya REVOKE. Ese último existe porque --no-privileges produce un archivo que restaura una base funcional con la bitácora editable.

Agregá el export PATH a tu perfil de shell, o el comando falla en cada terminal nueva.


api no tiene plan gratuito viable: necesita un proceso vivo, y eso cuesta entre 3 y 6 dólares al mes. Es la única pieza que no puede arrancar gratis.

Con Railway:

  1. Nuevo proyecto → conectar el repo aquazaku-api
  2. Detecta el Dockerfile solo
  3. Cargar las variables (abajo)
  4. Healthcheck: /health — devuelve { status: 'ok' }
  5. Generar el dominio público, o dejarlo interno si web también vive ahí

El contenedor ya está listo para una plataforma así, y eso se verificó:

Qué Estado
Lee el PORT que inyecta la plataforma ✅
Escucha en 0.0.0.0, no en localhost ✅
Corre como usuario node, no como root ✅
tini como init, para que las señales lleguen ✅

El 0.0.0.0 es el que más despliegues rompe: atado a localhost, el contenedor arranca sano y nadie lo alcanza desde afuera.

Variable Valor
NODE_ENV production
DATABASE_URL pooler transacción, rol aquazaku_app
DATABASE_MIGRATION_URL pooler sesión, rol postgres
BETTER_AUTH_SECRET 32 caracteres o más, al azar
BETTER_AUTH_URL https://api.<dominio>
COOKIE_DOMAIN app.<dominio> — el subdominio, no el padre
WEB_PUBLIC_URL https://app.<dominio>
MAIL_TRANSPORT resend
RESEND_API_KEY del panel de Resend
EMAIL_FROM noreply@<dominio>

El esquema valida al importarse: si falta una, el proceso no arranca y dice cuál. Es a propósito — un servidor a medio configurar que igual levanta es peor que uno que no levanta.

Ninguno de los dos servicios tiene startCommand: los dos arrancan con el CMD del Dockerfile, que es pnpm start y nada más. Lo que los diferencia es que staging tiene además un pre-deploy command, que corre antes.

Servicio startCommand Pre-deploy command
Producción (vacío — CMD del Dockerfile) —
Staging / preview (vacío — CMD del Dockerfile) pnpm db:sync-preview && pnpm db:seed

Staging sí se migra solo, en su pre-deploy, y está bien: es descartable, y si su schema quedó mal, no debería levantar. db:sync-preview además verifica que la inmutabilidad del audit_log aterrizó en el schema preview — confiar en que un sed sobre las migraciones dejó los permisos correctos no es verificarlo.

Que eso corra importa más de lo que parece: durante meses el comando de staging fue db:migrate pelado, sin el sync-, así que esa verificación no se hizo nunca. Un deploy sano hoy termina con la línea que lo dice:

✓ audit_log en "preview" es append-only para aquazaku_app.

En Vercel: importar el repo aquazaku-web. Dos variables, ninguna NEXT_PUBLIC_:

Variable Valor
API_INTERNAL_URL https://api.<dominio>
WEB_PUBLIC_URL https://app.<dominio>

Que ninguna sea pública no es casualidad: el navegador nunca le habla a api (ADR-0002), así que no hay nada que exponer al cliente.


Registro Apunta a
app Vercel
api Railway
SPF, DKIM y verificación lo que indique Resend

Los dos registros van en DNS only — nube gris. No es preferencia:

Con la nube naranja Qué pasa
Vercel marca el dominio «Invalid Configuration» no valida ni emite el certificado
ERR_TOO_MANY_REDIRECTS dos capas negociando HTTPS
Vercel pierde visibilidad del tráfico sus protecciones dejan de ver quién entra
Doble caché Cloudflare y Vercel cacheando lo mismo

Vercel lo desaconseja explícitamente: recomienda no poner un proxy inverso delante.

Y para api la nube naranja no compra nada aunque funcionara: el navegador nunca le habla a api. El único cliente es el servidor de Vercel. Un CDN delante de un servidor que nadie visita es una capa más que puede fallar, a cambio de nada.

El sitio oficial en la raíz es otra historia: ahí la nube naranja sí sirve, porque es contenido público, cacheable, y sin un proveedor detrás que se queje.

Registro Nombre Para qué
TXT resend._domainkey DKIM: firma cada correo. Lo da Resend
TXT send SPF del subdominio de envío — Resend manda por Amazon SES
TXT _dmarc v=DMARC1; p=none; rua=mailto:dmarc@<dominio>
MX @ el correo entrante, que NO es de Resend

p=none a propósito. No rechaza nada: pide reportes. Aquazaku tiene dos remitentes legítimos —Resend para los correos del sistema y el reenvío del registrador, que ya está en el SPF de la raíz—, y con p=reject de entrada, uno mal alineado dejaría de entregar correo sin que nadie se entere.

Se endurece a p=quarantine y después a p=reject cuando los reportes muestren que todo lo legítimo pasa.

El panel de Resend dice si los registros están. No dice si el correo llega.

Se manda uno real —«olvidé mi contraseña» desde el login— y se miran los encabezados de autenticación en el cliente de correo (en Gmail: los tres puntos → «Mostrar original»):

SPF: PASS
DKIM: PASS
DMARC: PASS

Los tres en PASS es lo que evita que el correo de recuperación caiga en spam. Y un correo de recuperación en spam es una cuenta perdida.


  1. Entrar con el admin sembrado y cambiar la contraseña — el sistema lo exige antes de dejar pasar a ningún lado
  2. Cargar el precio de los tres productos y activarlos
  3. Cargar las 40 bases con su consecutivo, que ya tienen sticker desde 0001
  4. Registrar el primer cliente de verdad
  5. Hacer una venta
  6. Verificar que aparece en el extracto del contador

El paso 6 es el que prueba que el sistema entero está conectado: toca ventas, stock, retornables y reportes en una sola operación.


Riesgo Estado
Supabase Free no tiene respaldos Cubierto a mano con pnpm db:respaldo — manual, depende de acordarse
Supabase Free se pausa a la semana sin uso Cubierto: api late contra la base cada 6 h
Vercel Hobby prohíbe el uso comercial Sin cubrir. El seguro es que web/Dockerfile funciona: mudar a Railway son horas

Ninguno bloquea arrancar. Los tres se resuelven con dinero el día que el sistema haya demostrado que vale la pena gastarlo.