Escritos

Self-hosting de Next.js con Dokploy y Docker

¿Cuándo tiene sentido dejar Vercel? Veremos output standalone, Dockerfiles multi-stage, por qué las migraciones de base de datos van en el entrypoint y no en el build, y una línea de código que evita que se pierda SIGTERM.

Furkan Özay2 de septiembre de 2026 · 8 min de lectura
czCZAY.DEV // EDITORIALES
CONTAINER & DEVOPS

#Araçlar · 8 MIN DE LECTURA

FURKAN ÖZAYVOL. 2026 // #EC95

Vercel es la elección correcta para la mayoría de los proyectos: cero configuración, previews automáticas y una red edge global. Este artículo no te dice que lo dejes. Pero si llega el momento en que tengas que hacerlo —porque los costes del servidor se han vuelto impredecibles, porque tu base de datos necesita estar en la misma máquina o porque los datos deben permanecer dentro de las fronteras locales—, aquí te explico cómo es realmente esa migración.

Lo que sigue es lo que me encontré al alojar student.czay.dev en mi propio servidor. Escribir la imagen de Docker es la parte fácil; lo difícil es la secuencia de arranque del contenedor.

¿Qué hace Dokploy?

Es un PaaS autoalojado: conectas tu repositorio de Git y construye y despliega tu imagen en cada push. Por debajo usa Docker y Traefik: el enrutamiento de dominios y los certificados SSL de Let's Encrypt están totalmente automatizados. En esencia, hace gran parte del trabajo de Vercel en un solo VPS.

Explicación: Lo que ganas y lo que pierdes

Ventajas: costes fijos y predecibles, tener tu base de datos y tu app en la misma red, control total sobre dónde residen los datos y ningún límite de tiempo de ejecución.

Desventajas: no hay red edge global, escalar es cosa tuya, y las actualizaciones del servidor y las copias de seguridad son tu responsabilidad. Una sola máquina es un único punto de fallo.

1. output: "standalone"

Sin esta línea, tu imagen pesará innecesariamente cientos de megabytes.

TypeScript
// next.config.ts
const nextConfig = {
	output: "standalone" as const,
};

Esto le indica a Next.js que rastree y empaquete solo los node_modules que realmente se usan, generando un directorio autocontenido en .next/standalone. Ya no necesitas copiar toda la carpeta node_modules a tu imagen runner.

Si estás en un monorepo, necesitas una línea más:

TypeScript
import path from "node:path";
 
const nextConfig = {
	output: "standalone" as const,
	outputFileTracingRoot: path.join(import.meta.dirname, "../.."),
};

Si te la saltas, tu imagen se construirá correctamente pero fallará en tiempo de ejecución con un error de "module not found". Esto ocurre porque pnpm eleva las dependencias a la raíz del repositorio, mientras que Next.js, por defecto, solo busca dentro del directorio de la aplicación.

2. Dockerfile multi-stage

Tres etapas: dependencias, build y runner.

Ejemplo: Etapa de dependencias

dockerfile
FROM node:22-alpine AS base
RUN corepack enable
WORKDIR /repo
 
FROM base AS deps
# Copy only the manifest files first: if source code changes, this layer
# is served from cache and pnpm install does not run again.
COPY pnpm-lock.yaml pnpm-workspace.yaml package.json ./
COPY apps/student/package.json apps/student/
COPY packages/ui/package.json packages/ui/
 
RUN --mount=type=cache,id=pnpm,target=/root/.local/share/pnpm/store \
    pnpm install --frozen-lockfile --filter student...

Dos detalles reducen los tiempos de build de minutos a segundos:

  • Copia primero los manifests. La cache de capas de Docker comprueba si los archivos han cambiado. Si copias tu código fuente antes de ejecutar pnpm install, cada cambio de código invalidará la cache y provocará una reinstalación completa de todas las dependencias.
  • Usa --mount=type=cache para conservar el store de pnpm entre builds.

3. Imagen runner: pequeña y sin privilegios

dockerfile
FROM node:22-alpine AS runner
WORKDIR /app
 
ENV NODE_ENV=production
ENV PORT=3003
ENV HOSTNAME=0.0.0.0
 
RUN addgroup -g 1001 -S nodejs && adduser -S nextjs -u 1001
 
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/.next/static ./apps/student/.next/static
COPY --from=build --chown=nextjs:nodejs /repo/apps/student/public ./apps/student/public
 
USER nextjs
EXPOSE 3003

HOSTNAME=0.0.0.0 es imprescindible. Si se queda en localhost por defecto, el servidor solo será accesible desde dentro del contenedor y Traefik no podrá enrutar hacia él. Es la causa más común del famoso error "el contenedor está en ejecución pero me da un 502".

USER nextjs es igual de importante: ejecutar los contenedores como root determina la gravedad de una brecha en caso de exploit.

Advertencia: Un error que solo aparece en un clon limpio

Mi carpeta public/ estaba vacía y Git no rastrea los directorios vacíos. En local todo funcionaba porque la carpeta existía; pero en el clon limpio del servidor el directorio no existía, y la instrucción COPY anterior fallaba con un error "not found". Lo arreglé añadiendo una sola línea a la etapa de build:

dockerfile
RUN mkdir -p apps/student/public

La diferencia entre tu espacio de trabajo local y un clon limpio explica buena parte de los bugs que se detectan durante el deploy.

4. El verdadero reto: la secuencia de arranque

Si tu aplicación tiene base de datos, ¿cuándo ejecutas las migraciones de esquema?

No durante la etapa de build. Durante el build no tienes acceso a la base de datos, ni deberías tenerlo; la misma imagen debe poder desplegarse contra cualquier instancia de base de datos. Además, los reinicios, el escalado y los rollbacks de Dokploy reutilizan la misma imagen: no puedes incrustar el esquema de la base de datos en una imagen estática.

El enfoque correcto es un script de entrypoint de arranque:

Ejemplo: docker-entrypoint.sh

Bash
#!/bin/sh
set -e
 
cd /app/apps/student
 
echo "[entrypoint] running schema migrations…"
node scripts/migrate.cjs
 
# These two do NOT fail the deployment
set +e
node scripts/seed-content.cjs --dagitim
node scripts/create-admin.cjs --dagitim
set -e
 
echo "[entrypoint] starting server…"
cd /app
exec node apps/student/server.js

Las tres decisiones de aquí son deliberadas:

¿Qué errores deben hacer fallar el deploy? Si la migración del esquema falla, el contenedor no debe arrancar en absoluto: ejecutar la app con un esquema desactualizado fallará en la primera solicitud, y la causa raíz será más difícil de depurar. Pero si falla la carga de contenido inicial (seed) o la creación de la cuenta de administrador, el deploy debe continuar; un panel vacío sigue siendo accesible y ambas cosas se pueden arreglar manualmente más tarde.

set -e / set +e es lo que impone esta separación: cualquier error del primer bloque detiene el script de inmediato, mientras que los errores del segundo bloque se ignoran.

Precaución: Sin exec, Dokploy matará el contenedor

El exec de la última línea no es solo estético. Sin exec, Node se ejecuta como un subproceso del script de shell, lo que significa que la señal SIGTERM de Docker llega al shell y nunca alcanza el proceso de Node.

Como resultado, la solicitud de parada agota el tiempo de espera y Docker mata el contenedor con SIGKILL. Tu app nunca se apaga de forma ordenada durante los deploys, dejando conexiones abiertas a la base de datos y solicitudes colgadas en el limbo.

exec reemplaza el proceso: Node pasa a ser el PID 1 y recibe la señal directamente.

5. El healthcheck debe esperar a las migraciones

dockerfile
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
  CMD node -e "fetch('http://127.0.0.1:'+(process.env.PORT||3003)+'/giris').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"

--start-period=40s es crítico: la aplicación no puede responder mientras se ejecutan las migraciones. Sin este periodo de gracia, Dokploy marcará el contenedor como "unhealthy" y lo reiniciará, relanzando las migraciones y atrapándote en un bucle infinito.

Pedir una página real para la comprobación también es deliberado: la ruta raíz podría devolver una redirección, lo que no demuestra que la app esté completamente operativa.

6. Variables de entorno: ¿en build o en tiempo de ejecución?

Esta distinción causa muchos más dolores de cabeza en Docker.

Importante: NEXT_PUBLIC_ se incrusta en tiempo de build

Las variables con el prefijo NEXT_PUBLIC_ se inyectan en el bundle del cliente durante el build. Pasarlas al contenedor en tiempo de ejecución no tiene ningún efecto: el JavaScript compilado dentro de la imagen mantendrá sus valores antiguos.

Debes pasarlas como argumentos de build (build args) en Dokploy. Si las cambias, debes lanzar un rebuild completo; un simple reinicio no basta.

En cambio, las variables del lado del servidor (DATABASE_URL, claves de API) deben proporcionarse en tiempo de ejecución y no deben acabar nunca dentro de la imagen.

Por eso .dockerignore es tan importante:

plaintext
node_modules
**/node_modules
**/.next
**/.turbo
.git
 
# Environment files are not included in the image
.env
.env.*
**/.env
**/.env.*
!**/.env.example

Si copias tu archivo .env a la imagen, tus secretos quedarán incrustados en las capas de la imagen, es decir, persistirán en el historial del registry aunque borres la imagen final.

Checklist

Resumen: Antes de lanzar

  • ¿Está habilitado output: "standalone"? ¿Y outputFileTracingRoot si estás en un monorepo?
  • ¿Está definido HOSTNAME=0.0.0.0? (Si no, tendrás un 502)
  • ¿El contenedor se ejecuta como root? (No debería)
  • ¿Las migraciones de esquema se ejecutan al arrancar o en el build? (Deberían ejecutarse al arrancar)
  • ¿La última línea usa exec? (Si no, espera un SIGKILL en cada deploy)
  • ¿Es --start-period lo bastante largo para que terminen tus migraciones?
  • ¿Las variables NEXT_PUBLIC_* se pasan como build args?
  • ¿Está .env incluido en .dockerignore?
  • ¿Tienes configuradas copias de seguridad de la base de datos? (En Vercel no era tu trabajo, pero ahora sí)

El último punto es el que más se pasa por alto. Mudarte a tu propio servidor reduce costes, pero carga toda la responsabilidad sobre tus hombros: las actualizaciones, las copias de seguridad y la monitorización ahora son cosa tuya. Si no tienes tiempo para ello, quedarte en Vercel es en realidad más barato, no en dólares, sino en tiempo.

Artículo relacionado: Gestión de monorepos con Turborepo

Hoja de ruta y herramienta interactivas

Sigue leyendo