ADR-0009 — Cada pieza en su plataforma, con deploy en cada push
Estado: Aceptado — revisado el mismo día, y ejecutado el 5-sep-2026 Fecha: 2026-08-28 Deciden: Mao
Contexto
Sección titulada «Contexto»Aquazaku llegó a M9 sin una sola decisión de infraestructura escrita. No había ADR, no había Dockerfile, y la palabra «internet» no aparecía en ninguno de los ocho ADR anteriores ni en el dominio.
Eso importaba más de lo que parecía: todo el MVP se diseñó web-first asumiendo que la planta tiene conexión, y esa suposición nunca se dijo en voz alta ni se validó. Se validó al empezar esto: el servicio de internet en Campo de la Cruz es continuo. El día que haya que operar sin conexión será una PWA con sincronización, y eso es M8 — no una restricción de hoy.
Lo que el sistema exige del hosting
Sección titulada «Lo que el sistema exige del hosting»El hosting no es una elección libre. Hay dos condiciones que salen del código:
1. Veinte REVOKE repartidos en nueve migraciones. Las bitácoras
—audit_log, movimientos_stock, movimientos_base, movimientos_insumo,
lineas_de_compra— son append-only, y esa garantía tiene dos mitades: los
triggers y los permisos. ADR-0006
explica por qué viven en la base.
Cualquier Postgres que no permita CREATE ROLE y REVOKE deja la mitad de esa
garantía sin poner — y lo hace en silencio, porque las migraciones corren
igual.
2. pg_advisory_xact_lock en el parque de botellones, y un pool de diez
conexiones persistentes.
Alternativas evaluadas
Sección titulada «Alternativas evaluadas»Opción A — Todo en una máquina con Docker
Sección titulada «Opción A — Todo en una máquina con Docker»- ✅ Un artefacto, una factura, una cosa que respaldar.
- ✅ Control total de los roles de Postgres.
- ✅ El BFF le habla a
apipor red privada: la API no es alcanzable desde internet. - ❌ Los respaldos quedan a cargo de uno. Un
pg_dumppor cron que hay que acordarse de sacar de la máquina. Es la parte más débil.
Opción B — Todo repartido: Vercel para web, algo para api, Postgres gestionado
Sección titulada «Opción B — Todo repartido: Vercel para web, algo para api, Postgres gestionado»- ✅ Respaldos y escalado gestionados.
- ❌ El BFF deja de ser interno. Hoy
weble habla aapipor la red privada de Docker. Repartido, ese tráfico sale a internet y la API tiene que quedar expuesta. No es fatal —hay HTTPS, validación deOriginy sesión— pero es un cambio de postura que el diseño no asumía. - ❌ El pool de diez conexiones asume un proceso vivo. En serverless cada instancia abre el suyo y se agota el límite.
- ❌ Tres proveedores para ocho personas y un mostrador.
Opción C — Postgres gestionado, aplicación en una máquina
Sección titulada «Opción C — Postgres gestionado, aplicación en una máquina»- ✅ Respaldos gestionados con recuperación a un punto en el tiempo.
- ✅ El BFF sigue hablándole a
apipor red privada. - ✅ Solo cambia una variable de entorno respecto de la opción A.
- ❌ Dos proveedores en vez de uno.
- ❌ Latencia de red entre la aplicación y la base, en vez de un socket local.
Decisión
Sección titulada «Decisión»Cada pieza en su plataforma, y cada una se despliega sola con un push:
| Pieza | Dónde | Disparador |
|---|---|---|
web |
Vercel | push a main |
api |
Railway, construyendo su Dockerfile | push a main |
| Postgres | Supabase | — |
Se decidió la opción C, y se revisó el mismo día
Sección titulada «Se decidió la opción C, y se revisó el mismo día»La primera versión de este ADR eligió la opción C —aplicación en una máquina— y la razón fue buena: los respaldos gestionados.
Lo que estaba mal era el peso que se le dio a un costo: administrar un servidor no se termina nunca. Actualizaciones del sistema, renovación de certificados, y ser la persona que lo arregla a las dos de la mañana. Para un negocio de ocho personas sin nadie de sistemas, eso no es un detalle — es el trabajo que va a quedar sin hacer.
Mao lo puso en una frase: solo quiere empujar código al repo y que eso actualice producción. Con las tres plataformas, eso sale de fábrica.
El BFF sobrevive, y eso hay que decirlo con precisión
Sección titulada «El BFF sobrevive, y eso hay que decirlo con precisión»En «Alternativas» se anotó en contra que repartirlo hace que el BFF «deje de ser interno». Es cierto a medias, y la mitad que importa se mantiene.
Lo que ADR-0002 protege es que el browser
nunca hable con api. Eso sigue igual: el navegador le habla a Vercel, y el
servidor de Vercel le habla a api.
Lo que sí cambia es que api queda alcanzable desde internet, en vez de vivir
en una red privada. Sigue protegida por la sesión, la matriz de permisos y la
validación de Origin que Better-Auth exige en toda petición que cambie estado.
Es una capa menos, no la capa.
El pool tampoco es un problema
Sección titulada «El pool tampoco es un problema»Se anotó que un pool de diez conexiones «asume un proceso vivo». Es cierto, y
por eso api va a una plataforma de contenedores de larga vida —Railway o
Fly— y no a funciones. El proceso queda arriba, con su pool, igual que en una
máquina propia.
Lo que se verificó antes de decidir, y no se supuso
Sección titulada «Lo que se verificó antes de decidir, y no se supuso»La condición no negociable se probó contra una instancia real:
| Qué | Resultado |
|---|---|
CREATE ROLE aquazaku_app |
✅ el rol postgres puede, sin ser superusuario |
Los veinte REVOKE |
✅ toman |
| Las once migraciones | ✅ 27 tablas, 13 triggers |
| Bitácoras después de migrar | ✅ SELECT INSERT, sin UPDATE ni DELETE |
pg_advisory_xact_lock por el pooler de transacción |
✅ toma y libera |
UPDATE audit_log con el rol de la aplicación |
✅ permission denied |
El último es el que cierra la decisión: la garantía se sostiene en caliente y por el camino que usa la aplicación de verdad, no en un test sintético.
Consecuencias
Sección titulada «Consecuencias»Hay DOS cadenas de conexión, y confundirlas rompe cosas
Sección titulada «Hay DOS cadenas de conexión, y confundirlas rompe cosas»| Variable | Pooler | Puerto | Por qué |
|---|---|---|---|
DATABASE_URL |
transacción | 6543 | La aplicación. pg_advisory_xact_lock se libera al cerrar la transacción, así que este modo le sirve. |
DATABASE_MIGRATION_URL |
sesión | 5432 | Las migraciones. Necesitan DDL, CREATE ROLE y REVOKE, que el modo transacción no sostiene. |
La versión de Postgres deja de coincidir
Sección titulada «La versión de Postgres deja de coincidir»Supabase corre 17.6 y el CI corría 16. Las migraciones pasaron contra 17, pero eso era una brecha de paridad: un bug propio de 17 no se vería hasta producción.
El CI ya está en 17. El entorno local puede quedar en 16 sin problema — la compuerta que decide si algo se mergea es el CI, y esa tiene que parecerse a producción, no a la máquina de quien escribe. Al revés no.
Hace falta un dominio propio, y no es opcional
Sección titulada «Hace falta un dominio propio, y no es opcional»forwardSetCookies reenvía la cookie de sesión tal cual viene de api,
incluido su atributo domain. Un navegador rechaza una cookie cuyo domain
no sea el del sitio que la sirvió, ni un ancestro suyo.
Con subdominios gratis —algo.vercel.app y algo.railway.app— son dominios raíz
distintos: la cookie no sobrevive y no hay sesión.
Con dominio propio, y la cookie en el subdominio de la app, no en el padre:
aquazaku.com → el sitio oficial, públicoapp.aquazaku.com → webapi.aquazaku.com → apiCOOKIE_DOMAIN=app.aquazaku.comLa aplicación sigue siendo portable
Sección titulada «La aplicación sigue siendo portable»api y web corren en contenedores contra cualquier Postgres que cumpla las
dos condiciones de arriba. Si una plataforma deja de convenir, cambia dónde se
construye el mismo Dockerfile — no la arquitectura.
Es lo mismo que permitió probar todo el despliegue contra un Postgres local en Docker antes de tocar una nube, y encontrar ahí seis bugs de empaquetado que ninguna lectura del archivo habría mostrado. Ese trabajo no se pierde: Railway y Fly construyen exactamente esos Dockerfiles.
La región de Supabase importa más de lo que parece
Sección titulada «La región de Supabase importa más de lo que parece»Una sola pantalla hace muchas consultas, así que la latencia entre api y la
base se multiplica por consulta. Pesa más que la distancia entre el usuario y
api.
Conviene que las dos queden cerca, y elegir la región antes de cargar datos: después es una migración.
Lo que quedó en pie — 5 de septiembre de 2026
Sección titulada «Lo que quedó en pie — 5 de septiembre de 2026»El ADR dejó de ser un plan. Esto es lo que corre, verificado desde afuera:
| Pieza | Dónde | Plan |
|---|---|---|
app.aquazaku.com |
Vercel | Hobby |
api.aquazaku.com |
Railway | Hobby |
| Postgres | Supabase us-east-1 |
Free |
| Correo | Resend | Free |
| DNS | Cloudflare, dominio en Namecheap | Free |
Los dos registros van en DNS only, nube gris. La raíz queda reservada para el sitio oficial.
Las pruebas, no las impresiones
Sección titulada «Las pruebas, no las impresiones»| Prueba | Resultado |
|---|---|
api /health |
200 {"status":"ok"} |
| Login con credenciales falsas | 401 INVALID_EMAIL_OR_PASSWORD |
/reportes/cartera, /clientes, /ventas sin sesión |
401 UNAUTHENTICATED |
web / y /modulos/* sin sesión |
307 → /login |
web /login |
200, con su formulario |
El 307 es el que prueba la arquitectura entera. Para redirigir,
getServerUser() corrió en Vercel, le habló a Railway servidor-a-servidor,
recibió un 401 y decidió. Es ADR-0002
funcionando entre dos proveedores distintos.
Y el 401 del login es el que prueba que la base responde: /health no
consulta nada, así que un 200 ahí no dice nada de Supabase.
Los dos bugs, y qué los resolvió
Sección titulada «Los dos bugs, y qué los resolvió»| Síntoma | Lo que dijo el log |
|---|---|
api en 500 |
password authentication failed for user "aquazaku_app" |
web en 500 |
API_INTERNAL_URL no está definida |
Ninguno se resolvió leyendo código. Los dos, leyendo un log.
El segundo se cerró en un vistazo porque el mensaje decía exactamente qué faltaba y por qué. Es el argumento entero a favor de escribir errores que expliquen, en vez de dejar que el framework tire un stack trace genérico.
Del primero quedó una regla: la contraseña de un rol de base de datos va en
ASCII puro. Entre el panel del proveedor, la URL de conexión y el driver hay
tres capas donde un carácter acentuado se puede mal codificar, y el síntoma
—password authentication failed— no menciona la codificación por ningún lado.
Lo que la capa gratuita no cubre
Sección titulada «Lo que la capa gratuita no cubre»| Riesgo | Estado |
|---|---|
| Supabase Free no tiene respaldos | Cubierto con pnpm db:respaldo, manual |
| 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 |
El latido, y por qué no hizo falta otra plataforma
Sección titulada «El latido, y por qué no hizo falta otra plataforma»El plan gratuito de Supabase pausa el proyecto tras una semana sin actividad de base de datos. Cualquier consulta reinicia ese reloj.
api ya es un proceso de larga vida —por eso corre en contenedores y no en
funciones—, así que late solo: consulta SELECT now() al arrancar y cada seis
horas. Un servicio de cron aparte habría sido una cuarta cuenta con su propia
forma de fallar en silencio.
Late al arrancar, no solo en el intervalo. Es el detalle que decide si funciona: si solo latiera cada seis horas, un contenedor que se reinicia seguido —cada deploy lo reinicia— podría no latir nunca, porque el temporizador vuelve a cero antes de cumplirse. El sistema quedaría sin latido justamente mientras más se trabaja en él.
Y el mismo hecho arregló el healthcheck: consultar para mantener la base
despierta y consultar para saber si contesta son la misma consulta. /health
ahora reporta lo que el latido descubrió en vez de un ok fijo.
Sigue devolviendo 200 aunque la base falle, a propósito: un 503 haría que
Railway reinicie el contenedor en bucle, y reiniciar api no levanta Supabase.
Lo que cambia es que el cuerpo lo dice y el log lo grita.
El ritmo del latido — fix del 9 de septiembre de 2026
Sección titulada «El ritmo del latido — fix del 9 de septiembre de 2026»La primera versión del latido avisaba cuando el último SELECT now() tenía más
de 30 minutos. El intervalo entre latidos es de 6 horas. La diferencia es
cinco veces el umbral, así que en teoría solo se alertaba en una de cada doce
corridas — en la práctica, avisaba el 92% del tiempo.
La causa: las dos puntas de la comparación no medían lo mismo. El umbral decía “30 minutos sin actividad de base”, pero el intervalo decía “6 horas entre consultas”. Con cualquier jitter de red, cualquier GC largo, cualquier redeploy intermedio, el timestamp del último latido se acercaba al umbral, y el monitor gritaba. El umbral y el intervalo tenían que ser del mismo orden de magnitud, o el sistema vivía gritando.
El fix: subir el umbral a 45 minutos y agregar un segundo criterio — la distancia al próximo latido esperado tiene que ser menor a 30 minutos. Si el intervalo se cumpliera exactamente, el último latido tendría 6 h 15 min al siguiente tick: está dentro de los 45 min, no avisa. Si el intervalo se estira a 8 horas por un delay, el último tendría 8 h 15 min: fuera de los 45 min, sí avisa — pero el siguiente tick lo arregla. La alerta pasa de 92% de las corridas a algo que solo se dispara cuando hay un problema real.
La doc operativa del latido y los tests que lo fijan viven en
api/src/lib/latido.ts y api/src/lib/__tests__/latido.test.ts. El
umbral es un número en el código: si cambia la frecuencia del intervalo
hay que cambiarlo también, o se vuelve a desbalancear.
Deuda anotada, no escondida
Sección titulada «Deuda anotada, no escondida»- Better-Auth no ve la IP del cliente detrás del proxy de Railway, así que el rate limit del login usa un balde compartido: un atacante bloquearía a las ocho personas de la planta junto con él.
- Los errores de
webhablan solo de desarrollo local («copiá.env.examplea.env.local»), consejo inútil en Vercel.
