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"]
| Sintaxis | Motivo |
|---|---|
node:24-alpine | Node.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=production | Frameworks como Express activan optimizaciones para producción |
npm ci en lugar de npm install | Instala 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=dev | No instala devDependencies |
npm cache clean --force | Elimina la caché de descargas de npm para que no quede en la imagen |
COPY --chown=node:node | Establece node como propietario de los archivos |
USER node | La 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
whoamidevuelvenode, no root.psmuestra que el PID denode server.jses 1.docker stopsolo tarda 0.15 segundos. En los registros apareceSIGTERM 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:
- Gestionar SIGTERM en el programa, como en
server.jsanterior (recomendado, permite terminar de forma ordenada). - Añadir
--init(en Compose, escribeinit: 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 noticedel 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 ciy[stage-1 4/5] RUN npm ci --omit=devse 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_modulesy los dos archivos de package; no incluyesrcnitsconfig.json. - La ausencia de
node_modules/typescriptindica quedevDependenciesno se incluyó en la imagen final. - Usa
--target buildpara construir también la primera etapa y compararlas:ts-app:buildocupa 297 MB y la imagen finalts-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/standaloneno incluyepublicni.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.0hace que Next.js escuche en todas las interfaces de red; de lo contrario, no se podrá acceder desde fuera del contenedor..dockerignoredebe excluir.nextynode_modules.- Si el proyecto no tiene una carpeta
public,COPY --from=build /app/publicfallará. Basta con crear una carpetapublicvací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 ciynext buildtardaron 14 y 17 segundos, respectivamente; son los pasos más largos de la compilación.- El contenedor solo contiene
server.js,.next,public,package.jsony elnode_modulesreducido (38.1 MB). - La primera etapa (con el
node_modulescompleto y el código fuente) ocupa 968 MB; la imagen final solo ocupa 295 MB, unos 53 MB más que la imagen basenode:24-alpine(242 MB).
Abre http://localhost:3000:

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
commandsobrescribe elCMDdel Dockerfile y ejecutanpm 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)..:/appmonta la carpeta local en/appdentro del contenedor.- /app/node_moduleses un truco habitual: usa un Volume anónimo para «ocultar»/app/node_modules. De lo contrario,.:/appharía que el contenedor viera la carpeta local, donde no existenode_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 →
synccopia los archivos al contenedor ynode --watchdetecta el cambio y se reinicia. - Al modificar
package.json→rebuildvuelve a construir la imagen (ejecutanpm cide 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 nodeyCMD ["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 --watchodocker 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