Desplegá un SaaS Next.js + Express con Docker en Dokploy (o Railway)

Desplegá un cliente Next.js y una API Express desde un monorepo con Docker: etapas de build, migraciones al arrancar, worker, env vars y Dokploy.

MC
Martin Coll

Un SaaS con Next.js y Express en un monorepo se despliega como tres contenedores armados con dos Dockerfiles: la API (apps/server/Dockerfile, etapa runner por defecto, puerto 3005), la app web (apps/client/Dockerfile, puerto 3004) y, opcionalmente, un worker de background armado con el mismo Dockerfile del server y --target worker. Los dos builds usan la raíz del repositorio como contexto de Docker, la API aplica las migraciones de Prisma cuando arranca, la app web necesita sus URLs públicas como build arguments, y detrás de un reverse proxy tenés que setear TRUST_PROXY=1. Esta guía recorre ese setup con los Dockerfiles, docker-compose.yml y railway.toml reales de useDeploy, y después lo lleva a Dokploy. Railway queda al final.

Qué se despliega

PiezaOrigenPuertoNotas
APIapps/server/Dockerfile, etapa runner3005Express sobre Bun, GET /health
Webapps/client/Dockerfile3004Next.js output: 'standalone', arranca con node server.js
Workerapps/server/Dockerfile, --target workerningunoprocesador de BullMQ, sin HTTP
Postgresgestionado o una base de Dokploy5432DATABASE_URL
Redisgestionado o una base de Dokploy6379rate limits y BullMQ, opcional para arrancar

Nada de esto es específico de Dokploy: las mismas imágenes corren en cualquier host con Docker.

La imagen del server

apps/server/Dockerfile tiene cinco etapas. Las que importan para el deploy:

  • deps copia package.json, bun.lock, turbo.json, el package.json de cada workspace, packages/ y el schema de Prisma, y corre bun install --frozen-lockfile. El schema tiene que estar porque el postinstall de la raíz ejecuta prisma generate.
  • builder empaqueta la API con bun build.
  • runtime copia solo lo que la API necesita (sin apps/client, sin tests).
  • worker y runner extienden runtime. runner es la última etapa a propósito, así un build sin --target igual produce la API.

El comando de arranque del runner aplica las migraciones y después reemplaza el shell por el server:

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD wget --no-verbose --tries=1 --spider http://localhost:3005/health || exit 1

CMD ["sh", "-c", "echo '→ prisma migrate deploy' && bunx prisma migrate deploy && exec bun run dist/index.js"]

El && importa: si una migración falla, el contenedor termina en vez de servir contra un schema que el código ya no espera. Solo el runner migra. El worker no, así que exactamente un proceso es dueño del rollout del schema (Prisma además toma un advisory lock como respaldo). La doc de Prisma sobre migrate deploy dice que lo corras como parte de tu pipeline de CI/CD para staging, testing o producción, y no desde tu laptop contra una base de producción.

El worker no tiene HTTP, así que no tiene healthcheck

FROM runtime AS worker
HEALTHCHECK NONE
CMD ["bun", "run", "src/bootstrap/worker.ts"]

Si el worker heredara el healthcheck HTTP, un orquestador (el comentario del Dockerfile nombra a Docker Swarm y a Dokploy) probaría /health, no recibiría respuesta, marcaría la tarea como no saludable y la reciclaría cada 70 segundos más o menos. HEALTHCHECK NONE lo resuelve, y el ajuste de la plataforma que importa es la build stage del servicio worker: si la dejás vacía, se construye runner.

La imagen del client

ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME
RUN cd apps/client && bun run build

Next.js inyecta las variables NEXT_PUBLIC_* en tiempo de build. Setearlas como variables de entorno de runtime del contenedor no hace nada por el bundle del navegador. Pasalas como build arguments y rebuildeá cuando cambien. NEXT_PUBLIC_API_URL es solo el origen de la API, sin /api/v1: cada consumidor agrega el prefijo.

La etapa final copia .next/standalone, los archivos estáticos y public/, y ejecuta node server.js con un usuario no root. Como es un monorepo, el output standalone conserva la estructura de workspaces (.next/standalone/apps/client/server.js).

Una variable es solo de runtime: INTERNAL_API_URL, que se usa para llamadas server a server desde el middleware de Next.js. Ese código corre dentro del contenedor, donde la URL de la API pensada para el navegador puede no ser alcanzable, así que en una red de Docker compartida la seteás a la dirección interna de la API, por ejemplo http://server:3005. Si no la definís, el middleware cae a NEXT_PUBLIC_API_URL, que sirve en un solo host con URLs públicas.

Probalo primero en local

El docker-compose.yml del repo construye las dos imágenes como lo haría una plataforma:

export DATABASE_URL=postgresql://...
export REDIS_URL=redis://...
export BETTER_AUTH_SECRET=$(openssl rand -hex 32)
docker compose up --build

No incluye Postgres ni Redis: espera que sean gestionados. Una base en tu propia máquina es alcanzable desde los contenedores como host.docker.internal, no como localhost. El compose le pasa DIRECT_URL a la API y por defecto vale DATABASE_URL (DIRECT_URL=${DIRECT_URL:-${DATABASE_URL}}), así que solo la exportás vos si DATABASE_URL apunta a un pooler. El client espera el healthcheck del server (depends_on: condition: service_healthy). Si esto anda, el build de la plataforma también. docker-compose.dev.yml es la variante todo en uno, con bases de datos y hot reload.

Variables de entorno

Zod rechaza que falte DATABASE_URL, BETTER_AUTH_SECRET o BETTER_AUTH_URL (ver apps/server/src/bootstrap/env.ts). CORS_ORIGINS, APP_URL y TRUST_PROXY tienen defaults que están mal en producción, y DIRECT_URL la exige el CLI de Prisma que migra en el boot (el propio Zod no la chequea):

VariableValor
DATABASE_URLconnection string de Postgres
BETTER_AUTH_SECRETal menos 32 caracteres, de openssl rand -hex 32
BETTER_AUTH_URLla URL pública de la propia API
CORS_ORIGINSel origen completo de la app web, separado por comas si hay varios
APP_URLla URL pública de la app web, usada en mails y redirects
TRUST_PROXY1 detrás de un reverse proxy
DIRECT_URLla pide el CLI de Prisma; igual a DATABASE_URL salvo que uses un pooler

REDIS_URL es opcional para arrancar, pero sin él los rate limits son por instancia y los jobs en background no corren. Si usás Polar con NODE_ENV=production, seteá POLAR_SERVER explícitamente (sandbox o production); el server se niega a arrancar en vez de caer en silencio al sandbox.

TRUST_PROXY merece una oración aparte. Es la cantidad de reverse proxies delante de Express. Detrás de Traefik (Dokploy), Railway o Cloudflare, si lo dejás en su valor por defecto 0, todos los visitantes parecen venir de la IP del proxy y comparten un único bucket de rate limit. Seteá TRUST_PROXY=1 en el servicio de la API. Mirá rate limiting en Better Auth (en inglés) para ver qué más depende de la IP del cliente.

Deploy en Dokploy

Dokploy construye aplicaciones desde un repositorio Git o una imagen y las expone con Traefik. La doc de build types lista las opciones; para este repo querés Dockerfile, que tiene tres campos. Creá una aplicación por servicio:

API

  1. Source: tu repositorio y branch.
  2. Build type Dockerfile: Dockerfile Path apps/server/Dockerfile, Docker Context Path . (la raíz del repo, como exige el header del Dockerfile), Docker Build Stage vacío.
  3. Environment: las variables de la tabla de arriba.
  4. Domain: host api.example.com, container port 3005, HTTPS activado con Let's Encrypt. Según la doc de dominios, el puerto se usa para el ruteo interno de Traefik y no se publica a internet, y tu DNS tiene que apuntar el host al servidor de Dokploy.

Web

  1. Mismo repositorio. Dockerfile Path apps/client/Dockerfile, Context Path ..
  2. Environment, como build-time arguments: NEXT_PUBLIC_API_URL=https://api.example.com y NEXT_PUBLIC_APP_NAME. La doc de Dokploy recomienda build-time secrets para lo sensible, porque los arguments persisten en la imagen; variables públicas como estas no son secretos.
  3. Domain: host app.example.com, container port 3004, HTTPS activado.

Worker (si usás jobs en background)

  1. Mismo Dockerfile que la API, Docker Build Stage worker, el mismo environment que la API.
  2. Sin dominio.

El orden importa una vez: seteá BETTER_AUTH_URL y CORS_ORIGINS de la API con los dominios finales, y después desplegá la web con la URL de la API como build argument. Si más adelante cambiás el dominio de la API, la web necesita un rebuild.

Verificá

curl -i https://api.example.com/health   # 200

Los logs de deploy de la API tienen que mostrar → prisma migrate deploy antes de que arranque el server. GET /ready es el chequeo más profundo (ejercita las dependencias) y es el que conviene alertar, mientras que /health es solo liveness. En una instalación nueva la app web redirige todas las rutas a /setup, un wizard de una sola vez que crea el primer admin de la plataforma, así que completalo apenas termine el primer deploy verde.

Deploy en Railway

El repo trae dos archivos de config. El railway.toml de la raíz apunta al Dockerfile del server con healthcheckPath = "/health", un timeout de 100 segundos y reinicios ON_FAILURE (10 como máximo). Para el servicio web, poné Config Path en apps/client/railway.toml. La forma son cuatro servicios: API, web, y los plugins de Postgres y Redis de Railway; la sintaxis de referencia DATABASE_URL=${{Postgres.DATABASE_URL}} mantiene las variables enlazadas al plugin. bun run deploy:railway automatiza el cableado de variables.

El primer deploy falla, a propósito: Zod rechaza que falte DATABASE_URL, BETTER_AUTH_SECRET o BETTER_AUTH_URL antes de que se abra el listener HTTP, y el healthcheck de Railway reporta el servicio como no disponible. Si falta DIRECT_URL, falla un paso después, cuando el CLI de Prisma carga el schema para migrar, y aborta el boot igual. Funciona después de setear las variables y redesplegar; el mensaje de error nombra la variable.

Errores comunes

SíntomaCausa
La web llama a localhost:3005 en producciónNEXT_PUBLIC_API_URL seteada en runtime, no como build argument. Rebuildeá.
El servicio del worker responde HTTP y los jobs nunca correnBuild stage vacía, así que Docker construyó runner (la API). Poné la build stage en worker.
El rate limiter bloquea a todos a la vezTRUST_PROXY en 0 detrás de un proxy.
El login anda pero se pierde la sesión después del redirectBETTER_AUTH_URL o CORS_ORIGINS no coinciden con los dominios reales, o la cookie no puede cruzar orígenes.
Cannot find module '@app/contracts' en el buildpackages/ copiado después de bun install, o el contexto de Docker no es la raíz del repo.
El arranque falla con un error de ZodFalta una variable requerida. Leé el mensaje, la nombra.

Preguntas frecuentes

¿Necesito Docker Compose en Dokploy?

No. Servicios Application separados a partir de los Dockerfiles son más simples. Dokploy también corre proyectos Compose, pero tendrías que traer tus propios servicios de base de datos y labels.

¿Por qué el contexto de Docker es la raíz del repo?

Las imágenes necesitan bun.lock, el package.json de la raíz y packages/* para resolver las dependencias workspace:*. Un contexto de apps/server no los vería.

¿Dónde corren las migraciones?

En el contenedor de la API al arrancar, con prisma migrate deploy. El worker nunca las corre. Si escalás la API a varias réplicas, el advisory lock de Prisma evita que migren al mismo tiempo.

¿Por qué no se ve mi cambio en una variable NEXT_PUBLIC_?

Esos valores quedan fijos en el bundle de JavaScript en el build. Cambiá el build argument y redesplegá para que se reconstruya la imagen.

¿Puedo correr el worker dentro del proceso de la API?

useDeploy lo mantiene separado para que la carga de jobs no ahogue los requests HTTP y para que el worker no tenga healthcheck. En un deploy muy chico podés omitir el worker, a costa de que los jobs en background no corran.


useDeploy trae los dos Dockerfiles, los archivos de compose, las configs de Railway y las guías de deploy en las que se basa este recorrido. Mirá la documentación de deploy con Docker y la integración con Better Auth.