|

Dockerfile Next.js producción: de 1.97 GB a 287 MB

En pocas palabras: Un Dockerfile de producción con build multi-stage (deps, builder, runner), output: ‘standalone’ y usuario non-root reduce la imagen de Next.js 16.4.2 de 1.97 GB a 287 MB con node:24-slim, o 229 MB con node:24-alpine, según mediciones publicadas el 26 de octubre de 2026.

Un Dockerfile de Next.js para producción, bien armado, con build multi-stage, output standalone y usuario non-root, reduce el tamaño de la imagen final de 1.97 GB a 287 MB usando node:24-slim, o a 229 MB con node:24-alpine. Esas cifras no son una estimación: las midió fabwaseem comparando tres builds de la misma app en la misma máquina, publicado el 26 de octubre de 2026. Nosotros no repetimos el benchmark acá, pero sí vamos a bajar un escalón más: qué decisión tomar cuando tu proyecto no es el create-next-app de laboratorio sino algo con dependencias propias.

Un Dockerfile de Next.js para producción es un archivo de build con tres etapas (deps, builder, runner) que instala dependencias, corre next build con output: 'standalone', y copia solo el servidor rastreado, los assets estáticos y public/ a una imagen final mínima que ejecuta node server.js como usuario no-root. Next.js genera esa salida automáticamente desde la versión 12 con output file tracing.

En 30 segundos

  • Multi-stage + standalone bajó el peso de la imagen de 1.97 GB a 287 MB (slim) o 229 MB (alpine), medido por fabwaseem sobre Next.js 16.4.2 con create-next-app.
  • output: 'standalone' genera .next/standalone con un server.js mínimo, pero no copia public/ ni .next/static: eso hay que hacerlo a mano.
  • Correr node server.js en vez de next start evita cargar node_modules completo y deja que Node reciba las señales de Docker como PID 1.
  • El usuario node (UID 1000) ya viene en las imágenes oficiales de Node.js; alcanza con COPY --chown=node:node, no hace falta crear uno nuevo.
  • Elegir entre slim y alpine no es solo cuestión de MB: depende de si tus dependencias con binarios nativos ya están probadas contra musl.

¿Cuánto pesa una imagen Docker de Next.js sin optimizar frente a una con multi-stage y standalone?

Una imagen armada en un solo stage sobre node:24, con COPY . ., npm ci && npm run build y npm start, pesa 1.97 GB. La misma app con build multi-stage y standalone cae a 287 MB sobre node:24-slim, según la guía de fabwaseem publicada el 26 de octubre de 2026, o a 229 MB sobre node:24-alpine.

Los archivos de la aplicación dentro del runner (la carpeta /app) ocupan 59 MB según esa misma medición; el resto es la imagen base de Node. (Casi siete veces más chica que el single-stage, nada mal para un cambio que es básicamente reordenar el Dockerfile.) Fabwaseem construyó y corrió los tres builds contra un create-next-app fresco, con App Router, TypeScript y Tailwind, sobre Next.js 16.4.2 y Node.js 24, en la misma máquina. Tus números van a variar con tus dependencias, pero el orden de magnitud se repite porque el trace de standalone solo arrastra los paquetes que el servidor importa de verdad, no todo node_modules. Esto se conecta con lo que analizamos en armar un pipeline CI/CD seguro hasta producción.

BuildImagen baseTamaño medido
Single stage (COPY . ., npm ci, npm start)node:241.97 GB
Multi-stage + standalonenode:24-slim287 MB
Multi-stage + standalonenode:24-alpine229 MB
dockerfile next.js producción diagrama explicativo

¿Qué hace exactamente output: ‘standalone’ en next.config?

output: 'standalone' le dice a next build que genere una carpeta .next/standalone con un server.js mínimo y solo los archivos de node_modules que tus rutas necesitan de verdad. Next.js usa @vercel/nft para rastrear estáticamente imports, requires y uso de fs en cada página.

Ojo con esto: standalone no copia public/ ni .next/static. La documentación oficial asume que esas carpetas van a un CDN, así que en el Dockerfile hay que copiarlas al lado de server.js a mano. Te olvidás de este paso y tu CSS, tus chunks de JS y tus imágenes devuelven 404 en producción, sin ningún error claro que te avise por qué.

PORT y HOSTNAME controlan dónde escucha el server. Dentro de un contenedor querés HOSTNAME=0.0.0.0, porque si no el server puede terminar atado solo a la interfaz interna del contenedor y el puerto publicado no contesta nada. Si tu proyecto es un monorepo e importa paquetes de afuera de su carpeta, necesitás outputFileTracingRoot apuntando a la raíz del repo para que el trace los incluya.

node:24-slim o node:24-alpine: cómo decidir sin adivinar

Acá es donde la mayoría copia el Dockerfile de un tutorial y elige la imagen más chica sin preguntarse por qué el ejemplo oficial de Vercel usa slim como default. La diferencia no es cosmética: node:24-slim usa glibc, la misma biblioteca de C que corre en casi cualquier distro Linux estándar, mientras que node:24-alpine usa musl, más liviana pero con menos compatibilidad garantizada para módulos nativos (compilados en C, C++ o Rust).

El ejemplo oficial with-docker de vercel/next.js lo deja explícito: slim ronda los 226 MB de base y ofrece “mejor compatibilidad que la libc musl de Alpine, que puede causar problemas con algunos paquetes npm”, mientras que alpine baja a unos 110 MB pero exige haber probado que tus dependencias funcionan bien con musl. Traducido a criterios concretos:

  • Elegí alpine si el tamaño de imagen es una restricción real (pulls lentos, cuota de storage en el registry, flota de contenedores grande) y ya verificaste que ninguna dependencia con partes nativas te va a romper el build.
  • Elegí slim si no revisaste esa compatibilidad, si tenés ORMs, drivers de base de datos o libs de procesamiento de imágenes distintas de sharp, o simplemente si preferís el camino que menos sorpresas da en producción. Es el default que usa el ejemplo oficial por algo.
  • En cualquiera de los dos casos, pineá la versión con el ARG NODE_VERSION del Dockerfile, así cambiar de una a otra (o subir de versión de Node) es una sola línea, no una reescritura.

Ejemplo hipotético (para pensar la decisión, no es una medición real): imaginá un equipo que ya tiene el Dockerfile multi-stage funcionando con node:24-slim y quiere bajarle unos 60-90 MB más antes de migrar a un registry con cuota ajustada. Antes de tocar el ARG NODE_VERSION y pasar a 24-alpine, lo razonable sería repasar el package.json buscando paquetes con partes nativas —clientes de base de datos, libs de compresión distintas de sharp, cualquier cosa que compile binarios— y confirmar si cada uno publica build para musl o si hay que agregar paquetes extra en el Dockerfile para que compilen ahí. Si todo cierra, el cambio es directo: se rebuildea con el --platform correcto y se confirma con id que el contenedor sigue corriendo como node. Si alguna dependencia solo tiene binarios para glibc y no hay build alternativo, conviene quedarse en slim aunque la imagen pese más: un contenedor liviano que no levanta no resuelve nada.

¿Por qué usar node server.js en vez de next start dentro del contenedor?

Porque node server.js corre el mismo server de Next.js sin necesitar el árbol completo de node_modules ni el CLI de next, y arranca como proceso PID 1 directo en vez de pasar por un wrapper de npm.

¿Qué cambia eso en la práctica? Que cuando hacés docker stop, la señal SIGTERM le llega a Node directamente. Si en cambio tenés next start corriendo detrás de npm run start, la señal puede quedar atrapada en el proceso de npm y tu app tarda de más en cerrar conexiones o directamente no cierra limpio. El ejemplo oficial de vercel/next.js con Docker sigue exactamente esta misma lógica.

¿Cómo correr el contenedor de Next.js con un usuario non-root?

Se usa el usuario node (UID 1000) que ya viene incluido en todas las imágenes oficiales de Node.js, con USER node y COPY --chown=node:node para que los archivos copiados le pertenezcan. Hay que crear la carpeta .next y hacerle chown antes de copiar nada ahí, porque Next.js escribe su cache de prerender y de optimización de imágenes en esa carpeta mientras corre. Ya lo cubrimos antes en diseñar una arquitectura AWS de producción.

La recomendación sale directo de las mejores prácticas de Docker y Node.js del equipo de Node.js: no hace falta crear un usuario propio, porque las imágenes oficiales ya traen uno. Según documenta fabwaseem en su prueba, correr id dentro del contenedor construido devolvió uid=1000(node) gid=1000(node).

Dos errores de permisos explican la mayoría de los crashes con “EACCES”: copiar archivos sin --chown, con lo que quedan en manos de root y el directorio de cache queda de solo lectura para node; o montar un volumen sobre /app/.next/cache que en el host pertenece a root. La solución en ese segundo caso es cambiar el dueño de la carpeta en el host, o directamente no montar el volumen.

¿Por qué sharp no funciona dentro de mi contenedor de Next.js?

Con Next.js 15 en adelante, en general no hay que hacer nada especial: next trae sharp como dependencia y el trace de standalone copia sus binarios nativos de libvips para Linux junto con el resto. El requisito real es correr npm ci adentro del contenedor, no en tu Mac o Windows, para que se instalen los binarios Linux correctos.

En la prueba que documenta fabwaseem, el node_modules del runner terminó con sharp, @img/sharp-linux-x64 y @img/sharp-libvips-linux-x64 sin ninguna configuración extra, y require('sharp') funcionó adentro del contenedor corriendo con sharp 0.35.5.

Si el warning de “sharp missing in production” aparece igual, hay tres sospechosos en orden: estar copiando un node_modules del host en vez de dejar que npm ci corra adentro de la imagen; estar buildeando en Apple Silicon para un server x64 sin pasar --platform linux/amd64; o necesitar forzar el archivo con outputFileTracingIncludes: { '/*': ['node_modules/sharp/**/*'] } en next.config. Sobre eso hablamos en errores comunes al desplegar desde GitHub.

Variables de entorno: el error que más rompe builds de Next.js en Docker

Las variables prefijadas con NEXT_PUBLIC_ se inyectan en el JavaScript del navegador al momento de next build, así que tienen que pasarse como ARG en el stage de build. Todo lo demás (DATABASE_URL, claves de auth, tokens) lo lee el server en runtime, y se pasa con docker run -e o la configuración de variables de tu plataforma, nunca horneado dentro de la imagen.

El tema es que una vez buildeado, un valor NEXT_PUBLIC_ queda congelado en el bundle. Cambiarlo después implica rebuildear, no hay otra. Si el build necesita un secret (por ejemplo para instalar un paquete privado o prerenderizar con datos de una API), no uses ARG: queda visible en el historial de la imagen. Para eso existen los secret mounts de BuildKit, que exponen el valor solo durante un paso de RUN y después desaparecen.

Errores comunes al dockerizar Next.js para producción

  • No copiar public/ y .next/static manualmente. Standalone no las incluye por diseño; si te las olvidás, tu app sirve el HTML pero el CSS y las imágenes tiran 404.
  • Copiar archivos sin –chown. Quedan como propiedad de root y el proceso node no puede escribir en .next/cache, lo que termina en crashes con EACCES apenas alguien visita una página dinámica.
  • Pasar secrets como ARG en vez de runtime o secret mount. Un build argument queda recuperable desde el historial de la imagen con docker history, así que cualquiera con acceso al registry puede verlo.
  • Buildear en Apple Silicon sin –platform linux/amd64. Si tu server de producción es x64 y vos buildeás en una Mac M-series, Docker puede elegir los binarios nativos equivocados para sharp u otras dependencias con addons.
  • Cambiar a alpine sin chequear compatibilidad nativa. La imagen baja de peso, pero si alguna dependencia solo tiene binarios para glibc, el build o el arranque falla y la “optimización” te sale más cara en horas de debugging que en MB ahorrados.

Dato aparte si pensás alojar este contenedor en un VPS propio: donweb.com ofrece un plan de Cloud Hosting con Docker y Docker Compose preinstalados, listo para producción, así que vale la pena revisarlo antes de armar la infraestructura desde cero — el Dockerfile sigue siendo tu responsabilidad.

Preguntas Frecuentes

¿Cómo hacer un Dockerfile de Next.js para producción?

Se arma con tres stages: deps instala dependencias con npm ci, builder corre next build con output: 'standalone' activado, y runner copia .next/standalone, .next/static y public/ a una imagen limpia basada en node:24-slim o node:24-alpine que arranca con node server.js como usuario node.

¿Qué es output: ‘standalone’ en Next.js?

Es una opción de next.config que hace que next build genere una carpeta .next/standalone con un servidor mínimo y solo los módulos de node_modules que las rutas usan, gracias al análisis estático de @vercel/nft. Esa carpeta corre sola con node server.js, sin necesidad de reinstalar dependencias. Tema relacionado: servir la app con Caddy y Docker.

¿Por qué mi imagen Docker de Next.js pesa tanto?

En la mayoría de los casos es porque estás copiando node_modules completo y el código fuente a la imagen final en vez de usar un build multi-stage con standalone. Una imagen single-stage sobre node:24 llegó a pesar 1.97 GB en la medición de referencia, contra 287 MB con la misma app en multi-stage sobre node:24-slim.

¿node:24-slim o node:24-alpine: cuál conviene?

Slim (glibc) es el default más seguro si no verificaste compatibilidad de tus dependencias nativas; es el que usa el ejemplo oficial de Vercel. Alpine (musl) pesa menos —el ejemplo oficial de Vercel compara 226 MB contra 110 MB de imagen base—, pero conviene reservarlo para cuando ya confirmaste que ningún paquete con binarios nativos depende exclusivamente de glibc.

¿Cómo correr un contenedor de Next.js sin usuario root?

Agregando USER node al final del Dockerfile, usando el usuario con UID 1000 que ya viene incluido en las imágenes oficiales de Node.js, y copiando los archivos con COPY --chown=node:node para que ese usuario tenga permisos de escritura sobre .next.

¿Por qué sharp no funciona dentro de mi contenedor de Next.js?

Casi siempre es porque se instaló node_modules fuera del contenedor (en Mac o Windows) y después se copió adentro, trayendo binarios nativos que no sirven para Linux. La solución es correr npm ci dentro del stage de build del Dockerfile y, si buildeás en Apple Silicon para un server x64, agregar --platform linux/amd64 al comando docker build.

Conclusión

La diferencia entre un Dockerfile de Next.js para producción armado a las apuradas y uno con multi-stage, standalone y usuario non-root no es cosmética: son 1.97 GB contra 287 MB o 229 MB, según lo que midió fabwaseem sobre la misma app. Eso impacta directo en tiempos de pull, de deploy y en cuánto pagás de almacenamiento en tu registry. La elección entre slim y alpine es la parte que ese benchmark no resuelve por vos: depende de qué dependencias nativas arrastra tu proyecto puntual, no de un número de MB.

Si ya tenés un Dockerfile de Next.js corriendo en single-stage, el cambio concreto que conviene hacer esta semana es activar output: 'standalone' en next.config, pasar a tres stages, y chequear con docker images cuánto bajó el tamaño antes y después. Recién ahí, con el multi-stage ya funcionando, tiene sentido evaluar si alpine te sirve o si slim es la opción más tranquila para tu stack.

Fuentes

Te puede interesar...