Contenido

Tutorial de Node.js con Docker: despliega aplicaciones con Express, TypeScript y Next.js

Introducción a Dockerfile ya incluye un ejemplo muy sencillo con Node.js. En esta sección se reúnen los aspectos que debes tener en cuenta en un proyecto de producción, con resultados reales para cada recomendación.

Express: archivos del proyecto

express-app/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
├── .dockerignore
└── compose.yaml        ← solo se usa en modo de desarrollo

package.json:

{
  "name": "express-app",
  "version": "1.0.0",
  "scripts": { "start": "node server.js", "dev": "node --watch server.js" },
  "dependencies": { "express": "^5.1.0" }
}

server.js:

const express = require("express");
const app = express();

app.get("/", (req, res) => res.json({ message: "Hello from Express", node: process.version }));

const server = app.listen(3000, () => console.log("Listening on port 3000"));

process.on("SIGTERM", () => {
  console.log("SIGTERM received, closing server");
  server.close(() => process.exit(0));
});

Las últimas líneas gestionan SIGTERM: al recibir la señal de parada, primero dejan de aceptar conexiones nuevas, esperan a que terminen las solicitudes en curso y después finalizan el proceso. Más adelante comprobarás por qué hace falta este código.

Generar package-lock.json sin instalar Node.js

npm ci necesita package-lock.json. Si no tienes Node.js instalado localmente, puedes generarlo con un contenedor:

$ docker run --rm -v "$(pwd):/app" -w /app node:24-alpine npm install --package-lock-only

up to date, audited 66 packages in 3s

28 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

Comandos equivalentes en Windows (selecciona una pestaña para cambiar):

docker run --rm -v "$(pwd):/app" -w /app node:24-alpine npm install --package-lock-only
docker run --rm -v "${PWD}:/app" -w /app node:24-alpine npm install --package-lock-only
docker run --rm -v "%cd%:/app" -w /app node:24-alpine npm install --package-lock-only

--package-lock-only solo genera el archivo de bloqueo; no crea node_modules.

Dockerfile de Express para producción

FROM node:24-alpine

ENV NODE_ENV=production
WORKDIR /app

COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force

COPY --chown=node:node . .

USER node
EXPOSE 3000
CMD ["node", "server.js"]
SintaxisMotivo
node:24-alpineNode.js 24 es una versión LTS (soporte a largo plazo); la variante alpine ocupa poco. Si la compilación de un módulo nativo falla en alpine, cambia a node:24-slim
NODE_ENV=productionFrameworks como Express activan optimizaciones para producción
npm ci en lugar de npm installInstala estrictamente según package-lock.json, por lo que el resultado de cada compilación es el mismo; si el archivo de bloqueo no coincide con package.json, falla directamente
--omit=devNo instala devDependencies
npm cache clean --forceElimina la caché de descargas de npm para que no quede en la imagen
COPY --chown=node:nodeEstablece node como propietario de los archivos
USER nodeLa imagen oficial incluye el usuario node para evitar ejecutar como root
CMD ["node", "server.js"]El formato exec ejecuta node directamente

.dockerignore:

node_modules
npm-debug.log
dist
.env
.git

Asegúrate de excluir node_modules. El node_modules del host puede incluir módulos nativos compilados para otro sistema operativo (Windows, macOS). Al copiarlos al contenedor Linux, pueden producir errores y sobrescribir la versión que npm ci acaba de instalar.

Compila la imagen:

$ docker build -t express-app .

[+] Building 4.4s (10/10) FINISHED                                                                                                     docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 263B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                                                                                0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 82B                                                                                                                 0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 31.48kB                                                                                                             0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                                    0.0s
 => [3/5] COPY package.json package-lock.json ./                                                                                                 0.0s
 => [4/5] RUN npm ci --omit=dev && npm cache clean --force                                                                                       3.6s
 => [5/5] COPY --chown=node:node . .                                                                                                             0.1s
 => exporting to image                                                                                                                           0.5s
 => => exporting layers                                                                                                                          0.3s
 => => exporting manifest sha256:8917f79cf02c3ec90fb6b405b264d6947606b4483a1a54c9136d3d897c055cfe                                                0.0s
 => => exporting config sha256:38cb9b0da0deb6a0d8682ddb8bf8705ccccec4315c3e58075409bc2f4b07c82a                                                  0.0s
 => => exporting attestation manifest sha256:84686e085a4e55d8b9480ab770fb2b2e55b93126c779a2a58fd9647b434fb638                                    0.0s
 => => exporting manifest list sha256:b571835fa0e0ed6b89f34a1a725e847dd40b13a61bc13c9aadf30c405db340af                                           0.0s
 => => naming to docker.io/library/express-app:latest                                                                                            0.0s
 => => unpacking to docker.io/library/express-app:latest                                                                                         0.2s

Ejecuta el contenedor y comprueba el resultado:

$ docker run -d --name express -p 3000:3000 express-app
4d0122c37234c4396ed8cb924d4c206c77c07f2db19b9a9a89ea83eeb53831df

$ curl -s http://localhost:3000
{"message":"Hello from Express","node":"v24.21.0"}

$ docker exec express whoami
node

$ docker exec express ps
PID   USER     TIME  COMMAND
    1 node      0:00 {MainThread} node server.js
   18 node      0:00 ps

$ time docker stop express
express
real	0m 0.15s
user	0m 0.00s
sys	0m 0.01s

$ docker logs express
Listening on port 3000
SIGTERM received, closing server
  • whoami devuelve node, no root.
  • ps muestra que el PID de node server.js es 1.
  • docker stop solo tarda 0.15 segundos. En los registros aparece SIGTERM received, closing server, lo que confirma que el programa recibió la señal y se cerró correctamente.

Prueba práctica: por qué docker stop tarda 10 segundos

Elimina las líneas que gestionan SIGTERM de server.js y guárdalo como server-nohandler.js. Ejecútalo con la misma imagen:

$ docker run -d --name nohandler express-app node server-nohandler.js
9278a6a9ad9bab77e08edcb06b52baed2f372b0f98248ebf2bed344a96c4f4a3

$ time docker stop nohandler
nohandler
real	0m 10.13s
user	0m 0.01s
sys	0m 0.00s

$ docker inspect -f "{{.State.ExitCode}}" nohandler
137

Esta vez, docker stop tardó 10.13 segundos y el código de salida fue 137 (128 + 9, lo que indica que SIGKILL terminó el proceso por la fuerza).

La causa es una regla especial de Linux para el PID 1: las señales para las que el PID 1 no tiene un controlador registrado se ignoran. Cuando un programa normal recibe SIGTERM, la acción predeterminada es terminar. Sin embargo, en un contenedor, node es el PID 1 y, si no se ha escrito process.on("SIGTERM"), SIGTERM se ignora directamente. Docker esperó 10 segundos y, como el proceso no terminó, tuvo que enviar SIGKILL para terminarlo por la fuerza. Las solicitudes en curso se interrumpieron de inmediato.

Hay dos soluciones:

  1. Gestionar SIGTERM en el programa, como en server.js anterior (recomendado, permite terminar de forma ordenada).
  2. Añadir --init (en Compose, escribe init: true) para que el pequeño proceso init integrado en Docker sea el PID 1 y se encargue de reenviar las señales:
$ docker run -d --name withinit --init express-app node server-nohandler.js
6adf48ca4dc8eeed68ebd49689f417001ae4282d14995dd6299b62aa39b9c0b6

$ docker exec withinit ps
PID   USER     TIME  COMMAND
    1 node      0:00 /sbin/docker-init -- docker-entrypoint.sh node server-nohandler.js
    7 node      0:00 {MainThread} node server-nohandler.js
   15 node      0:00 ps

$ time docker stop withinit
withinit
real	0m 0.22s
user	0m 0.00s
sys	0m 0.00s

ps muestra que el PID 1 ahora es /sbin/docker-init y que node pasa a ser el PID 7. Como node ya no es el PID 1, se restaura la acción predeterminada de SIGTERM de terminar el programa, y docker stop solo tardó 0.22 segundos.

¿Y npm start?

Dockerfile.npm es igual que antes; solo cambia la última línea a CMD ["npm", "start"]:

$ docker build -q -f Dockerfile.npm -t express-npm .
sha256:b1de8acaa7f9b683dccff25420ff08edd33926938788525d304fa327c5ed5895

$ docker run -d --name npmstart express-npm
bf95495c7e1ea9f990a00980423d78d238a38acca6a653a858548b100a88d846

$ docker exec npmstart ps
PID   USER     TIME  COMMAND
    1 node      0:00 npm start
   18 node      0:00 {MainThread} node server.js
   25 node      0:00 ps

$ time docker stop npmstart
npmstart
real	0m 0.11s
user	0m 0.00s
sys	0m 0.01s

$ docker logs npmstart

> express-app@1.0.0 start
> node server.js

Listening on port 3000
SIGTERM received, closing server
npm notice
npm notice New major version of npm available! 11.19.0 -> 12.2.0
npm notice Changelog: https://github.com/npm/cli/releases/tag/v12.2.0
npm notice To update run: npm install -g npm@12.2.0
npm notice

npm es el PID 1 y node es su proceso hijo (PID 18). En la prueba, npm 11 reenvió SIGTERM a node, por lo que se detuvo en solo 0.11 segundos. En los registros también aparece SIGTERM received.

Aun así, se recomienda iniciar directamente con node:

  • Queda un proceso npm adicional en ejecución, que consume memoria extra.
  • Los mensajes de npm se mezclan en los registros (el npm notice del final es npm avisando que hay una nueva versión).
  • El reenvío de señales depende de la implementación de npm; no siempre es fiable con versiones antiguas de npm o cuando se usa un script de shell como envoltorio.

TypeScript: compilación multietapa

Para proyectos que necesitan compilarse (TypeScript, Babel o herramientas de empaquetado), usa compilaciones multietapa: en la primera etapa instala todos los paquetes y compila; en la segunda, instala solo los paquetes de producción y copia el resultado compilado de la primera etapa.

Proyecto de ejemplo ts-app:

ts-app/
├── package.json
├── package-lock.json
├── tsconfig.json
├── src/
│   └── index.ts
├── Dockerfile
└── .dockerignore

package.json:

{
  "name": "ts-app",
  "version": "1.0.0",
  "scripts": { "build": "tsc", "start": "node dist/index.js" },
  "dependencies": { "express": "^5.1.0" },
  "devDependencies": { "typescript": "^5.9.0", "@types/express": "^5.0.0", "@types/node": "^24.0.0" }
}

tsconfig.json:

{
  "compilerOptions": {
    "target": "ES2023",
    "module": "commonjs",
    "outDir": "dist",
    "rootDir": "src",
    "strict": true,
    "esModuleInterop": true
  }
}

src/index.ts:

import express, { Request, Response } from "express";

const app = express();
const port = Number(process.env.PORT ?? 3000);

app.get("/", (_req: Request, res: Response) => {
  res.json({ message: "Hello from TypeScript", node: process.version });
});

const server = app.listen(port, () => console.log(`Listening on port ${port}`));
process.on("SIGTERM", () => server.close(() => process.exit(0)));

Dockerfile:

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine
ENV NODE_ENV=production
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/index.js"]

Además de node_modules, .dockerignore también debe excluir dist para evitar que se copien archivos antiguos compilados localmente.

$ docker run --rm -v "$(pwd):/app" -w /app node:24-alpine npm install --package-lock-only --no-update-notifier

up to date, audited 78 packages in 5s

28 packages are looking for funding
  run `npm fund` for details

found 0 vulnerabilities

$ docker build -t ts-app .

[+] Building 16.7s (13/13) FINISHED                                                                                                    docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 391B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                                                                                0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 68B                                                                                                                 0.0s
 => [build 1/6] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                    0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 35.99kB                                                                                                             0.0s
 => CACHED [build 2/6] WORKDIR /app                                                                                                              0.0s
 => [build 3/6] COPY package.json package-lock.json ./                                                                                           0.0s
 => [stage-1 4/5] RUN npm ci --omit=dev && npm cache clean --force                                                                               7.0s
 => [build 4/6] RUN npm ci                                                                                                                       7.2s
 => [build 5/6] COPY . .                                                                                                                         0.1s
 => [build 6/6] RUN npm run build                                                                                                                8.3s
 => [stage-1 5/5] COPY --from=build /app/dist ./dist                                                                                             0.1s
 => exporting to image                                                                                                                           0.9s
 => => exporting layers                                                                                                                          0.6s
 => => exporting manifest sha256:58a91539ec3d0dbcee5d303babb4c767a09c85b53abe6c195a321e6fd2d80b2e                                                0.0s
 => => exporting config sha256:2213fe6fe167daa9912ceec2e6c55883f67baa9b637f7cefa4b6006741d51c7c                                                  0.0s
 => => exporting attestation manifest sha256:411a11454dc2f749351eb4f363d87425763a77331094da33f74e46fd273aeb88                                    0.0s
 => => exporting manifest list sha256:6ed92551a94607ebcbfbd4fa123342429cc46737266344add01c334b682ea897                                           0.0s
 => => naming to docker.io/library/ts-app:latest                                                                                                 0.0s
 => => unpacking to docker.io/library/ts-app:latest                                                                                              0.2s

$ docker run -d --name ts -p 3000:3000 ts-app
15ddc10ace29ece5e4cc013a30d2b990bba52b901b918154e64463d5ee581d46

$ curl -s http://localhost:3000
{"message":"Hello from TypeScript","node":"v24.21.0"}

$ docker exec ts ls
dist
node_modules
package-lock.json
package.json

$ docker exec ts ls node_modules | grep -c .
65

$ docker exec ts ls node_modules/typescript
ls: node_modules/typescript: No such file or directory

$ docker images --filter reference=ts-app
IMAGE           ID             DISK USAGE   CONTENT SIZE   EXTRA
ts-app:build    fcf8f6c88e74        297MB         73.9MB
ts-app:latest   6ed92551a946        250MB         62.8MB   U

Equivalente para Windows (haz clic en las pestañas para cambiar):

docker run --rm -v "$(pwd):/app" -w /app node:24-alpine npm install --package-lock-only --no-update-notifier
docker run --rm -v "${PWD}:/app" -w /app node:24-alpine npm install --package-lock-only --no-update-notifier
docker run --rm -v "%cd%:/app" -w /app node:24-alpine npm install --package-lock-only --no-update-notifier

Puntos clave:

  • [build 4/6] RUN npm ci y [stage-1 4/5] RUN npm ci --omit=dev se ejecutan casi al mismo tiempo. BuildKit detecta que las dos etapas no dependen entre sí y las construye en paralelo.
  • La imagen final solo contiene dist, node_modules y los dos archivos de package; no incluye src ni tsconfig.json.
  • La ausencia de node_modules/typescript indica que devDependencies no se incluyó en la imagen final.
  • Usa --target build para construir también la primera etapa y compararlas: ts-app:build ocupa 297 MB y la imagen final ts-app:latest, 250 MB.

Salida standalone de Next.js

node_modules de Next.js ocupa mucho espacio, pero en tiempo de ejecución solo se utiliza una pequeña parte. Al activar la salida standalone, next build analiza los archivos que realmente utiliza la aplicación y los reúne en .next/standalone, que incluye un node_modules reducido y el server.js necesario para iniciar el servidor.

Proyecto de ejemplo next-app:

next-app/
├── app/
│   ├── layout.js
│   └── page.js
├── public/
├── next.config.mjs
├── package.json
├── package-lock.json
├── Dockerfile
└── .dockerignore

next.config.mjs:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: "standalone",
};

export default nextConfig;

package.json:

{
  "name": "next-app",
  "version": "1.0.0",
  "private": true,
  "scripts": { "dev": "next dev", "build": "next build", "start": "next start" },
  "dependencies": { "next": "^16.4.0", "react": "^19.3.0", "react-dom": "^19.3.0" }
}

app/page.js (force-dynamic hace que la página se genere en el servidor con cada solicitud; así puedes confirmar que el servidor Node.js está funcionando):

export const dynamic = "force-dynamic";

export default function Home() {
  return (
    <main>
      <h1>Hello from Next.js in Docker</h1>
      <p>Node.js {process.version}</p>
      <p>Rendered at {new Date().toISOString()}</p>
    </main>
  );
}

Dockerfile:

FROM node:24-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production
COPY --from=build --chown=node:node /app/public ./public
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
ENV HOSTNAME=0.0.0.0 PORT=3000
CMD ["node", "server.js"]

Ten en cuenta lo siguiente:

  • .next/standalone no incluye public ni .next/static; debes copiarlos por separado. De lo contrario, a la página web le faltarán los CSS, JavaScript e imágenes.
  • HOSTNAME=0.0.0.0 hace que Next.js escuche en todas las interfaces de red; de lo contrario, no se podrá acceder desde fuera del contenedor.
  • .dockerignore debe excluir .next y node_modules.
  • Si el proyecto no tiene una carpeta public, COPY --from=build /app/public fallará. Basta con crear una carpeta public vacía.
$ ls -a
.
..
.dockerignore
Dockerfile
app
next.config.mjs
package-lock.json
package.json
public

$ docker build -t next-app .

[+] Building 35.7s (14/14) FINISHED                                                                                                    docker:default
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 482B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                                                                                0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 74B                                                                                                                 0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 33.45kB                                                                                                             0.0s
 => [build 1/6] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                    0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.0s
 => CACHED [build 2/6] WORKDIR /app                                                                                                              0.0s
 => [build 3/6] COPY package.json package-lock.json ./                                                                                           0.0s
 => [build 4/6] RUN npm ci                                                                                                                      14.3s
 => [build 5/6] COPY . .                                                                                                                         0.2s
 => [build 6/6] RUN npm run build                                                                                                               17.2s
 => [stage-1 3/5] COPY --from=build --chown=node:node /app/public ./public                                                                       0.2s
 => [stage-1 4/5] COPY --from=build --chown=node:node /app/.next/standalone ./                                                                   0.3s
 => [stage-1 5/5] COPY --from=build --chown=node:node /app/.next/static ./.next/static                                                           0.0s
 => exporting to image                                                                                                                           2.5s
 => => exporting layers                                                                                                                          1.6s
 => => exporting manifest sha256:c4c7e9e6017fcef276d48566c8abdef44faaef63030e3b7de7200dee31704008                                                0.0s
 => => exporting config sha256:01db87f2d8cbbf50e3bdb7f43bbee19b88ad6cde31600d80119a5e95a45e0411                                                  0.0s
 => => exporting attestation manifest sha256:6294f1b5a03f9b4eeabdbc13d9ba940f09b5985beae5d12a090884d7f1840a86                                    0.0s
 => => exporting manifest list sha256:9beae036b818492afcf23060792cd3d7931346272bc5ba7a207f16f8aaf07d1a                                           0.0s
 => => naming to docker.io/library/next-app:latest                                                                                               0.0s
 => => unpacking to docker.io/library/next-app:latest                                                                                            0.8s

$ docker run -d --name next -p 3000:3000 next-app
7f81f881182ce1789b49adf30c942a835204e411925b96db56ddaf3576c705e1

$ docker logs next
▲ Next.js 16.4.0
- Local:         http://localhost:3000
- Network:       http://0.0.0.0:3000
✓ Ready in 0ms
✓ Running next.config took 4ms

$ docker exec next ls -a
.
..
.next
node_modules
package.json
public
server.js

$ docker exec next du -sh node_modules
38.1M	node_modules

$ docker images --filter reference=next-app
IMAGE             ID             DISK USAGE   CONTENT SIZE   EXTRA
next-app:build    3d79e3a5a419        968MB          273MB
next-app:latest   9beae036b818        295MB         73.6MB   U
  • npm ci y next build tardaron 14 y 17 segundos, respectivamente; son los pasos más largos de la compilación.
  • El contenedor solo contiene server.js, .next, public, package.json y el node_modules reducido (38.1 MB).
  • La primera etapa (con el node_modules completo y el código fuente) ocupa 968 MB; la imagen final solo ocupa 295 MB, unos 53 MB más que la imagen base node:24-alpine (242 MB).

Abre http://localhost:3000:

Página de Next.js ejecutándose en Docker
Página de Next.js; la hora cambia con cada actualización, lo que indica que el servidor la genera en tiempo real

Entorno de desarrollo: recarga automática al modificar el código

Método 1: montar la carpeta del código

compose.yaml:

services:
  app:
    build: .
    command: ["npm", "run", "dev"]
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      NODE_ENV: development
  • command sobrescribe el CMD del Dockerfile y ejecuta npm run dev, es decir, node --watch server.js (el modo de vigilancia integrado de Node.js, que reinicia automáticamente el proceso cuando cambia un archivo).
  • .:/app monta la carpeta local en /app dentro del contenedor.
  • - /app/node_modules es un truco habitual: usa un Volume anónimo para «ocultar» /app/node_modules. De lo contrario, .:/app haría que el contenedor viera la carpeta local, donde no existe node_modules (o sí existe, pero es para otro sistema operativo), y el programa no encontraría express.
$ docker compose up -d

[+] Building 0.2s (12/12) FINISHED
 => [internal] load local bake definitions                                                                                                       0.0s
 => => reading from stdin 482B                                                                                                                   0.0s
 => [internal] load build definition from Dockerfile                                                                                             0.0s
 => => transferring dockerfile: 263B                                                                                                             0.0s
 => [internal] load metadata for docker.io/library/node:24-alpine                                                                                0.0s
 => [internal] load .dockerignore                                                                                                                0.0s
 => => transferring context: 82B                                                                                                                 0.0s
 => [1/5] FROM docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.0s
 => => resolve docker.io/library/node:24-alpine@sha256:ebfe2f90462722a7a4de65e91990e97fe0d401c70e0e762c5b53302f905ec1c1                          0.0s
 => [internal] load build context                                                                                                                0.0s
 => => transferring context: 31.48kB                                                                                                             0.0s
 => CACHED [2/5] WORKDIR /app                                                                                                                    0.0s
 => CACHED [3/5] COPY package.json package-lock.json ./                                                                                          0.0s
 => CACHED [4/5] RUN npm ci --omit=dev && npm cache clean --force                                                                                0.0s
 => CACHED [5/5] COPY --chown=node:node . .                                                                                                      0.0s
 => exporting to image                                                                                                                           0.0s
 => => exporting layers                                                                                                                          0.0s
 => => exporting manifest sha256:598eee33b39a29caaadb626ac5a43ae91fb56735f09794f8d8303f3373c9df1e                                                0.0s
 => => exporting config sha256:0acccf4eb17effd018f4e6c68845df68bd4d15b9659c4e0aeabea9d04682149a                                                  0.0s
 => => exporting attestation manifest sha256:a4c7af50552b6a58aeeafc3ad29e68921bf929ea4b331b85b9f8afa58a9184b0                                    0.0s
 => => exporting manifest list sha256:74dc3d863e3f716a52493ec5c98edd8170da21d1a11716ab93254b104f017bb0                                           0.0s
 => => naming to docker.io/library/express-app-app:latest                                                                                        0.0s
 => => unpacking to docker.io/library/express-app-app:latest                                                                                     0.0s
 => resolving provenance for metadata file                                                                                                       0.0s
[+] up 3/3
 ✔ Image express-app-app       Built                                                                                                              0.3s
 ✔ Network express-app_default Created                                                                                                            0.0s
 ✔ Container express-app-app-1 Started                                                                                                            0.7s

$ curl -s http://localhost:3000
{"message":"Hello from Express","node":"v24.21.0"}

Edita el mensaje de server.js con tu editor y cámbialo a Hello after edit; luego guarda el archivo. No hace falta volver a construir la imagen:

$ curl -s http://localhost:3000
{"message":"Hello after edit","node":"v24.21.0"}

$ docker compose logs app
app-1  |
app-1  | > express-app@1.0.0 dev
app-1  | > node --watch server.js
app-1  |
app-1  | Listening on port 3000
app-1  | Change detected in '/app/server.js'
app-1  | Restarting 'server.js'
app-1  | SIGTERM received, closing server
app-1  | Listening on port 3000

$ docker compose exec app ls node_modules | head -n 3
accepts
body-parser
bytes

En los registros aparecen, en este orden, Change detected in '/app/server.js', Restarting 'server.js' y nuestro propio mensaje SIGTERM received: node --watch también usa SIGTERM para indicar al proceso anterior que termine. Por último, ls node_modules confirma que el contenedor usa los paquetes instalados en la imagen.

Si el proyecto tiene devDependencies (por ejemplo, nodemon o TypeScript), la imagen de desarrollo no puede usar --omit=dev. Puedes añadir target: build bajo build para indicar que se use la primera etapa de la compilación en varias etapas.

Método dos: docker compose watch

La función watch de Compose no usa montajes, sino que detecta los cambios en los archivos locales y los copia automáticamente al contenedor (sync) o vuelve a construir la imagen (rebuild):

services:
  app:
    build: .
    command: ["node", "--watch", "server.js"]
    ports:
      - "3000:3000"
    develop:
      watch:
        - action: sync
          path: .
          target: /app
          ignore:
            - node_modules/
        - action: rebuild
          path: package.json
  • Al modificar el código → sync copia los archivos al contenedor y node --watch detecta el cambio y se reinicia.
  • Al modificar package.json → rebuild vuelve a construir la imagen (ejecuta npm ci de nuevo) y recrea el contenedor.
$ docker compose watch
... (se omite la salida de compilación e inicio)
 Container express-app-app-1 Started
Watch enabled
Syncing service "app" after 2 changes were detected

Después de guardar, Compose muestra Syncing service "app" after 2 changes were detected (al guardar desde un editor, a menudo se generan varios eventos de archivo). Vuelve a hacer la solicitud para ver el contenido nuevo. Pulsa Ctrl+C para terminar watch.

La ventaja de watch es que no le afectan las diferencias entre los sistemas de archivos del host y del contenedor. Es más fiable en Windows y macOS, y no requiere el truco de /app/node_modules.

Solución de problemas

No se detectan los cambios en los archivos en macOS / Windows

Al usar montajes, en algunos entornos los eventos de supervisión de archivos no llegan al contenedor. Puedes usar docker compose watch como se indicó arriba, o configurar la herramienta de desarrollo para que use sondeo, por ejemplo, estableciendo las variables de entorno CHOKIDAR_USEPOLLING=true y WATCHPACK_POLLING=true. En Windows, también puedes guardar el proyecto en el sistema de archivos de WSL 2.

npm ci tarda mucho

Comprueba que .dockerignore excluya node_modules y que primero copies package*.json antes de instalar. Así, esta capa se puede usar desde la caché cuando solo cambies el código. También puedes añadir un montaje de caché de BuildKit para compartir los paquetes descargados por npm entre varias compilaciones:

RUN --mount=type=cache,target=/root/.npm npm ci

Aparece npm notice en los registros

npm notice New major version of npm available! es un aviso de actualización de npm y no afecta a la ejecución. Puedes desactivarlo añadiendo ENV NPM_CONFIG_UPDATE_NOTIFIER=false al Dockerfile o --no-update-notifier al comando. Si inicias directamente con node, no aparecerá este mensaje.

Resumen

  • npm ci --omit=dev, USER node y CMD ["node", ...] en formato exec.
  • Si node es PID 1, debes gestionar SIGTERM o usar --init.
  • Para TypeScript y Next.js, usa compilaciones en varias etapas y coloca en la imagen final solo los archivos necesarios para la ejecución.
  • Durante el desarrollo, usa montajes + node --watch o docker compose watch.

Ejercicios

Ejercicio 1: Inicia server-nohandler.js con docker run --init. ¿Cuánto tarda docker stop? ¿Por qué?

Respuesta

Unos 0,2 segundos. --init hace que docker-init sea PID 1 y reenvíe las señales. node deja de ser PID 1 y se restablece la acción predeterminada de SIGTERM: terminar el proceso.

Ejercicio 2: ¿Qué ocurre si faltan public y .next/static en la imagen final de Next.js standalone?

Respuesta

La página se puede abrir, pero no se cargarán el CSS, JavaScript ni las imágenes. Estas dos carpetas no están en .next/standalone, así que debes copiarlas por separado con COPY.

Preguntas frecuentes

¿Cuál deberías usar, npm install o npm ci?

Usa npm ci en el Dockerfile. Instala estrictamente según package-lock.json, por lo que el resultado es siempre el mismo.

¿La imagen de Next.js es demasiado grande?

Activa output: "standalone". En la prueba, el tamaño pasó de 968 MB a 295 MB. Consulta este capítulo.

¿Los cambios en los archivos no activan la recarga después de montar el código en Windows?

Usa docker compose watch o configura las herramientas de desarrollo para sondear los cambios (CHOKIDAR_USEPOLLING=true, WATCHPACK_POLLING=true).

Actualizado 14 de octubre de 2026