Dockerización de una Aplicación de Next.js usando un Standalone Build
- Publicado el
Introducción
Docker ha ganado popularidad en los últimos años por brindar la facilidad de colocar aplicaciones dentro de contenedores. Estos contenedores pueden desplegarse en cualquier entorno y funcionar de la misma manera en todos ellos, es decir, un funcionamiento uniforme sin importar en dónde se ejecute la aplicación. Estos contenedores utilizan imágenes
, que son una copia o fotografía comprimida de una aplicación. Al colocarse en un contenedor, se muestran tal como son. Es de esas tecnologías que algunos estaban desesperados por que llegara, mientras que otros solo saben que la necesitan hasta que se enteran de ella.
Next.js, por su parte, es el framework de React más popular. Como toda aplicación de JavaScript que utiliza un empaquetador como webpack o Vite, para producción se utiliza una versión compilada de todo el proyecto. A esto se le conoce como build
(compilado). Un build tiene el objetivo de proporcionar la cantidad mínima de código para que la aplicación sea igual de funcional que en desarrollo. De esta forma, los archivos de JavaScript son muy ligeros y al navegador le toma el menor tiempo posible obtenerlos e interpretarlos para mostrar la interfaz de usuario o lo que sea que haga la aplicación.
Next.js específicamente ofrece una versión que reduce todavía más el tamaño del build: el Standalone Build. Si usamos Docker para crear una imagen para nuestra aplicación de Next.js, podremos llevar fácilmente esa gran aplicación que hemos construido a cualquier entorno sin preocuparnos por la compatibilidad o configuraciones adicionales. En este artículo, veremos cómo lograrlo.
Tabla de Contenido
Gestor de paquetes
En mi caso me gusta utilizar pnpm para reducir el tamaño en disco de la carpeta node_modules
. Así que el ejemplo de la imagen de Docker de Next.js utiliza este gestor de paquetes, pero puedes hacer ligeros ajustes para utilizar npm o yarn si prefieres.
Configuración de Next.js
En el archivo next.config.js
, necesitamos especificar que el tipo del build resultante sea standalone
al compilar la aplicación para producción. Para esto, hay que especificar lo siguiente:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: "standalone"
};
export default nextConfig;
De este modo, la "salida" de la aplicación es de tipo standalone
.
Dockerfile
El archivo que representa nuestra imagen de Docker es el Dockerfile. Comúnmente este archivo se coloca en la raíz del proyecto. Vamos a crearlo paso por paso.
Imagen base
Toda imagen de Docker parte de una imagen base. En este caso, cualquier proyecto de JavaScript que ejecute un servidor, necesitará de un entorno de ejecución como Node.js.Tomaremos como base la imagen de Docker de una versión de Node.js que sea compatible con nuestro proyecto. En mi caso, me gusta utilizar la versión Alpine de las imágenes, ya que es mucho más ligera. Sin embargo, hay que revisar que no haya problemas de compatibilidad al construir la imagen, de lo contrario, es necesario utilizar la versión "no Alpine" de la imagen. Para este ejemplo, utilizo la imagen de node:22.6.0-alpine3.19
como base.
FROM node:22.6.0-alpine3.19 AS base
Le colocamos un alias para reciclarlo en las diferentes etapas o stages
de la imagen.
Dependencias del sistema y de pnpm
La siguiente etapa es instalar las dependencias. En este caso, solo se necesita una dependencia del sistema: libc6-compat
. Aquí se menciona por qué.
FROM base AS build-deps
RUN apk add --no-cache libc6-compat
Debido a que pnpm no se incluye por defecto con Node.js, es necesario activarlo y colocar variables de entorno para que los paquetes instalados puedan cachearse.
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
Luego, hay que establecer el directorio de trabajo para tener una separación clara entre las carpetas del sistema y la carpeta de la aplicación. En este caso, se usa /app
.
WORKDIR /app
Ahora hay que copiar los archivos que contienen la información de las dependencias del proyecto e instalarlas.
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prefer-frozen-lockfile
Los argumentos --frozen-lockfile
y --prefer-frozen-lockfile
sirven para respetar las versiones especificadas en el lock file
de pnpm.
Para terminar con esta etapa, se agrega la librería sharp
. Esta es necesaria para optimizar imágenes en un entorno de producción en Next.js.
RUN pnpm add sharp
La etapa completa se ve así:
FROM base AS build-deps
RUN apk add --no-cache libc6-compat
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prefer-frozen-lockfile
RUN pnpm add sharp
Compilar la aplicación
La siguiente etapa es compilar la aplicación de Next.js. Es aquí donde está la clave para que la imagen funcione, pues el resto del Dockerfile no es nada diferente o que no encuentres en cualquier ejemplo. En esta etapa es necesario que se pasen como argumentos de compilación las variables de entorno que se utilicen en el proyecto y se establezcan antes de generar el build.
Esto se debe a que, como existen dos tiempos en los que funcionan las aplicaciones, tiempo de compilación y tiempo de ejecución, si las variables de entorno no están disponibles en tiempo de compilación, todos los recursos estáticos que las utilicen no tendrán un valor para ellas y la aplicación no funcionará correctamente. En este ejemplo, se utilizan tres variables de entorno: NEXT_PUBLIC_BACKEND_URL
, FRONTEND_URL
y JWT_SECRET
.
FROM base AS builder
ARG NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ARG FRONTEND_URL
ENV FRONTEND_URL=$FRONTEND_URL
ARG JWT_SECRET
ENV JWT_SECRET=$JWT_SECRET
Luego se activa pnpm, se establece el directorio de trabajo, se copian todos los archivos de la aplicación y se genera el build.
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN pnpm build
La etapa completa se ve así:
FROM base AS builder
ARG NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ARG FRONTEND_URL
ENV FRONTEND_URL=$FRONTEND_URL
ARG JWT_SECRET
ENV JWT_SECRET=$JWT_SECRET
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN pnpm build
Correr la aplicación
La última etapa es correr la aplicación. Para esto, primero se establece el entorno de Node de producción:
FROM base AS runner
ENV NODE_ENV=production
Por preferencia personal, se deshabilita la telemetría de Next.js. Es decir, básicamente no enviar datos de nuestra aplicación a Vercel para mejorar Next.js a través del diagnóstico de errores y métricas de uso.
ENV NEXT_TELEMETRY_DISABLED=1
También, como buena práctica, se recomienda utilizar un usuario que no sea root
en las imágenes de Docker. Esto, por ejemplo, evita brechas de seguridad en caso de que el contenedor tenga acceso a la red del host
. Para esto, se agrega un grupo nodejs
y un usuario nextjs
y se les asigna la propiedad de la carpeta .next
.
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
Después, se copian los archivos generados por el standalone build
para crear la misma estructura del build por defecto de Next.js.
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
Como creamos el usuario nextjs
, se necesita especificar que será el usuario utilizado.
USER nextjs
De igual manera, se requiere especificar el puerto expuesto del contenedor, así como el puerto de Node y el hostname
que utilizará, el cual será 0.0.0.0
porque se desconoce la dirección exacta.
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
Después, se indican las variables de entorno para el tiempo de ejecución de la aplicación a partir de los argumentos de compilación.
ARG NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ARG FRONTEND_URL
ENV FRONTEND_URL=$FRONTEND_URL
ARG JWT_SECRET
ENV JWT_SECRET=$JWT_SECRET
Pueden utilizarse variables de entorno que se indiquen en un archivo docker-compose.yml
o al correr el contenedor, sin embargo, no tendría sentido que las variables de entorno en este contexto fueran diferentes en tiempo de compilación y en tiempo de ejecución.
Finalmente, se arranca el servidor.
CMD ["node", "server.js"]
Archivo completo
El Dockerfile completo se ve así:
FROM node:22.6.0-alpine3.19 AS base
FROM base AS build-deps
RUN apk add --no-cache libc6-compat
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prefer-frozen-lockfile
RUN pnpm add sharp
FROM base AS builder
ARG NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ARG FRONTEND_URL
ENV FRONTEND_URL=$FRONTEND_URL
ARG JWT_SECRET
ENV JWT_SECRET=$JWT_SECRET
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
WORKDIR /app
COPY /app/node_modules ./node_modules
COPY . .
RUN pnpm build
FROM base AS runner
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
COPY /app/.next/standalone ./
COPY /app/.next/static ./.next/static
COPY /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ARG NEXT_PUBLIC_BACKEND_URL
ENV NEXT_PUBLIC_BACKEND_URL=$NEXT_PUBLIC_BACKEND_URL
ARG FRONTEND_URL
ENV FRONTEND_URL=$FRONTEND_URL
ARG JWT_SECRET
ENV JWT_SECRET=$JWT_SECRET
CMD ["node", "server.js"]
También puedes encontrar el archivo en este gist.
Conclusión
Crear una imagen de Docker para una aplicación de Next.js puede parecer intimidante por las consideraciones que hay que tener en cuenta. Además, existe la creencia popular de que alojar una aplicación de Next.js por uno mismo, es decir, fuera de Vercel, es complicado. No lo es. Al entender las partes clave, es en realidad sencillo.
Espero que con esta información puedas dockerizar tu aplicación de Next.js sin problemas. Y ya sabes, si tienes alguna duda o quieres compartir algo, déjalo en los comentarios :)