Apariencia
Infraestructura
Dónde corre el sistema, cómo se despliega y a dónde va a migrar. Para el diseño del software ver Arquitectura; para levantarlo en tu máquina ver Onboarding.
Estado
Primer despliegue a producción: 2026-08-07. Sin clientes reales todavía.
Reparto de dominios
Todo cuelga de micamaronera.com, registrado y con DNS en Cloudflare.
| Subdominio | Qué sirve | Dónde vive |
|---|---|---|
api.micamaronera.com | API REST (NestJS) | Railway |
admin.micamaronera.com | Panel de tenant | Cloudflare Pages |
platform.micamaronera.com | Panel de plataforma | Cloudflare Pages + Access |
docs.micamaronera.com | Este sitio | Cloudflare Pages + Access |
send.micamaronera.com | Rebotes y SPF del correo | Resend (automático) |
micamaronera.com · www. | Redirect a admin. | Cloudflare Redirect Rules |
Por qué subdominios de un mismo apex
No es estético: es lo que hace que la autenticación funcione sin debilitarla.
admin.micamaronera.com llamando a api.micamaronera.com es cross-origin pero same-site (mismo dominio registrable). Eso permite que la cookie httpOnly del refresh token siga con SameSite=Lax, que es el valor seguro (ADR-0014).
Con dominios distintos (ej. admin.pages.dev → api.up.railway.app) habría que bajar a SameSite=None, más débil y frágil ante bloqueadores de terceros. El síntoma sería confuso: el login funciona, pero la sesión se pierde en cada F5.
Infraestructura actual
[Técnico en campo] [Administrador] [Superadmin]
Flutter + Drift admin.micamaronera.com platform.micamaronera.com
(aún sin distribuir) Cloudflare Pages Cloudflare Pages + Access
│ │ │
└────────────────────────┼─────────────────────────┘
│ HTTPS · SameSite=Lax
▼
api.micamaronera.com
┌──────────────────────────────────────┐
│ Railway · US East (Virginia) │
│ Docker · healthcheck /api/v1/health│
│ pre-deploy: prisma migrate deploy │
└────────┬──────────────────┬──────────┘
│ red privada │ red privada (IPv6)
▼ ▼
Railway Postgres Railway Redis
(snapshots) (cache RBAC + idempotencia)
micamaronera.com → Resend (SMTP 587)Los tres servicios de Railway están en la misma región (US East / Virginia). No es opcional: si el backend queda en otra, cada query cruza el continente y la red privada pierde su razón de ser.
Componentes
| Componente | Servicio | Notas |
|---|---|---|
| API | Railway, imagen Docker | Dockerfile multi-stage, node:22-slim, usuario sin privilegios |
| Base de datos | Railway Postgres | Volumen persistente. Backups por snapshot, sin PITR |
| Cache | Railway Redis | Volumen persistente. Cache de permisos + idempotencia del outbox |
| Paneles web | Cloudflare Pages | Build automático desde main, CDN global, gratis |
| Documentación | Cloudflare Pages + Access | Acceso restringido por email |
| Correo | Resend (SMTP) | Dominio apex verificado, región São Paulo |
Variables de entorno en producción
Los valores viven en Railway y Cloudflare, nunca en el repositorio. Esta es la lista de qué se configura; el detalle de cada una está en .env.template de cada repo.
| Variable | Servicio | Nota |
|---|---|---|
DATABASE_URL | Railway | Referencia al servicio Postgres (hostname privado, ver abajo) |
REDIS_URL | Railway | Referencia al servicio Redis (ver abajo) |
REDIS_FAMILY | Railway | 6 — obligatorio, ver más abajo |
WEB_URL | Railway | https://admin.micamaronera.com — base de los enlaces de invitación |
CORS_ORIGINS | Railway | Los dos paneles. Nunca vacío: vacío = permitir todos |
COOKIE_SECURE | Railway | true |
SWAGGER_ENABLED | Railway | false |
JWT_*_SECRET | Railway | Tres secretos aleatorios, distintos de los de desarrollo |
MAIL_* | Railway | Resend: host smtp.resend.com, puerto 587, user resend |
VITE_API_URL | Cloudflare Pages | https://api.micamaronera.com/api/v1 (admin y platform) |
SUPERADMIN_EMAIL / _PASSWORD | Railway | Temporales — borrar tras el primer login |
Las dos conexiones de datos no se escriben a mano: se referencian al servicio vecino con la sintaxis de Railway, que resuelve al hostname privado interno (no sale a internet ni paga tráfico de salida):
bash
DATABASE_URL=${{Postgres.DATABASE_URL}}
REDIS_URL=${{Redis.REDIS_URL}}
REDIS_FAMILY=6PORT no se configura: lo inyecta Railway. Fijarlo a mano deja el servicio inalcanzable.
Procedimiento de despliegue
Backend — automático al pushear a main:
- Railway construye la imagen desde el
Dockerfile. - Corre el pre-deploy:
npx prisma migrate deploy. Si una migración falla, el deploy se aborta y la versión anterior sigue viva. - Levanta el contenedor y verifica el healthcheck en
/api/v1/health.
Paneles y docs — automático al pushear a main: Cloudflare Pages construye y publica.
Rollback: en Railway, Deployments → deploy anterior → Redeploy. Funciona solo si la migración es compatible hacia atrás — de ahí la regla de nunca hacer migraciones destructivas en un solo release (expandir primero, contraer en un release posterior).
Verificación post-despliegue
bash
curl https://api.micamaronera.com/api/v1/healthDebe responder "database": "connected".
El healthcheck es liveness, no readiness
/api/v1/health captura el error de base de datos y devuelve 200 igual, con database: "disconnected" en el cuerpo. Railway lo va a dar por sano aunque Postgres esté caído. Mirá el contenido, no el código de estado.
Checklist completo:
"database": "connected"en el health- Logs con
Conectado a Redisy sinRedis no disponible - F5 en una ruta interna de
admin.yplatform.no da 404 - El login en
platform.sobrevive a un F5 (valida la cookie) - Una invitación real llega a una casilla externa y el magic link activa la cuenta
/api/docsdevuelve 404
Gotchas conocidos
Cuatro cosas que rompieron el primer despliegue. Todas silenciosas o de síntoma engañoso.
REDIS_FAMILY=6 es obligatorio en Railway
La red privada de Railway es IPv6-only y ioredis resuelve DNS en IPv4 por defecto, así que redis.railway.internal no resuelve. El fallo es invisible: RedisService degrada a PostgreSQL por diseño y solo emite un warning con throttle. La app funciona, pero sin cache de permisos (una query a la base por request) y sin idempotencia del outbox.
Verificalo en los logs explícitamente. Ver ADR-0063.
Sin Pre-Deploy Command, la base queda vacía
Si el comando npx prisma migrate deploy no está configurado, el servicio arranca contra una base sin schema y muere en el onModuleInit de PermissionsService con P2021 relation "public.actions" does not exist, en bucle de reinicio.
El generador de Prisma no es determinista por defecto
moduleFormat e importFileExtension se infieren del entorno, y esa inferencia difiere entre macOS local y el contenedor de build de Linux: en el contenedor emite imports con extensión .ts que tsc (con module: commonjs) no reescribe, produciendo require("./internal/class.ts") y un ReferenceError: exports is not defined in ES module scope al arrancar. Están fijados explícitamente en prisma/schema.prisma — no quitar ese bloque.
El entrypoint es dist/src/main, no dist/main
PrismaService importa el cliente generado desde ../../generated/prisma/client, fuera de src/, lo que eleva el directorio raíz de la compilación. package.json → start:prod y el CMD del Dockerfile tienen que coincidir.
Costos aproximados
| Concepto | Aproximado |
|---|---|
| Railway (API + Postgres + Redis) | US$ 10-20/mes según uso |
| Cloudflare Pages ×3 | $0 |
| Cloudflare Access (≤50 usuarios) | $0 |
| Resend (hasta 3.000 emails/mes) | $0 |
| Dominio | ~US$ 10/año |
Crédito de prueba
Railway arrancó con crédito de prueba. Cuando se agote, los tres servicios se detienen. Cargar método de pago antes de poner datos de un cliente real.
Infraestructura objetivo
La capa de datos migra a servicios gestionados especializados. Está documentada, no ejecutada.
| Componente | Hoy | Objetivo | Por qué |
|---|---|---|---|
| Postgres | Railway | Neon | PITR real (Railway solo hace snapshots) + branching de base por entorno o PR |
| Redis | Railway | Upstash | TLS por defecto, pay-per-request; el uso real es mínimo |
| API | Railway | Railway | sin cambios |
| Estáticos | Cloudflare Pages | Cloudflare Pages | sin cambios |
Disparador
Cuando los datos de un cliente real vivan en producción.
Mientras la base esté vacía o solo tenga datos de demo, los snapshots de Railway alcanzan: si algo se rompe, se resetea y se resiembra. El día que haya información que no se puede regenerar, la diferencia entre snapshot diario y point-in-time recovery es la diferencia entre perder un día de trabajo de campo y no perder nada.
Por qué la migración es barata
Es un cambio de dos variables de entorno más un pg_dump/pg_restore. Cero cambios de código — precisamente porque tanto DATABASE_URL como REDIS_URL son connection strings y no host/puerto sueltos (ADR-0063).
Pasos previstos:
- Crear la base en Neon y el Redis en Upstash.
pg_dumpdesde Railway →pg_restoreen Neon (ventana de mantenimiento corta).- Cambiar
DATABASE_URLyREDIS_URLen Railway. - Con Upstash,
REDIS_FAMILYdeja de hacer falta (URL pública con TLS): quitarla. - Verificar el checklist post-despliegue completo.
- Recién ahí, dar de baja los servicios de datos de Railway.
Detalle de la decisión en ADR-0062.
Qué NO cambia
El backend sigue en Railway. Migrarlo también significaría rehacer build, dominio y variables sin ganar nada: Railway hace bien lo que hace con contenedores. Lo que no hace bien —para el nivel de garantía que pide el dato de un cliente— es ser una base de datos.