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.
// 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:
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
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=cachepara conservar el store de pnpm entre builds.
3. Imagen runner: pequeña y sin privilegios
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 3003HOSTNAME=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:
RUN mkdir -p apps/student/publicLa 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
#!/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.jsLas 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
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:
node_modules
**/node_modules
**/.next
**/.turbo
.git
# Environment files are not included in the image
.env
.env.*
**/.env
**/.env.*
!**/.env.exampleSi 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"? ¿YoutputFileTracingRootsi 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-periodlo bastante largo para que terminen tus migraciones? - ¿Las variables
NEXT_PUBLIC_*se pasan como build args? - ¿Está
.envincluido 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
Desarrollador Frontend Junior 2026
Lo que realmente necesitas saber para conseguir un trabajo de frontend junior en 2026 y lo que puede esperar, a partir de ofertas reales.
¿SQL o NoSQL?
Responde seis preguntas y te diré qué tipo de base de datos encaja con tu proyecto, y por qué.
Sigue leyendo
- CZES8 sitios web gratuitos que deberías guardar en favoritos
8 sitios web gratuitos que deberías guardar en favoritos
20 de septiembre de 2026 · 1 min de lectura - CZESDrizzle ORM: gestión de esquema y migraciones en producción
Drizzle ORM: gestión de esquema y migraciones en producción
2 de septiembre de 2026 · 7 min de lectura