Conteúdo
Tutorial de Docker para aplicações Node.js: Express, TypeScript e Next.js
Introdução ao Dockerfile usou um exemplo básico de Node.js. Esta seção reúne os pontos que você deve observar em projetos de produção e inclui os resultados de testes práticos para cada recomendação.
Express: arquivos do projeto
express-app/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
├── .dockerignore
└── compose.yaml ← usado apenas no modo de desenvolvimento
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));
});
As últimas linhas tratam o SIGTERM: quando o processo recebe o sinal de encerramento, ele para de aceitar novas conexões, espera as solicitações em andamento terminarem e, então, encerra. Mais adiante, você verá por que isso é necessário em um teste prático.
Gere o package-lock.json sem instalar o Node.js
O npm ci precisa do package-lock.json. Se você não tem Node.js instalado localmente, pode gerar o arquivo usando um contêiner:
$ 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 no Windows (selecione uma aba para alternar):
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 gera apenas o arquivo de lock, sem criar node_modules.
Dockerfile de produção para Express
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"]
| Forma | Motivo |
|---|---|
node:24-alpine | Node.js 24 é uma versão LTS (suporte de longo prazo); a variante alpine é pequena. Se um módulo nativo falhar ao compilar no alpine, use node:24-slim |
NODE_ENV=production | Frameworks como Express ativam otimizações para produção |
npm ci em vez de npm install | Instala estritamente de acordo com package-lock.json, produzindo o mesmo resultado em cada build; falha imediatamente se o arquivo de lock não corresponder a package.json |
--omit=dev | Não instala devDependencies |
npm cache clean --force | Remove o cache de downloads do npm, para que ele não fique na imagem |
COPY --chown=node:node | Define o proprietário dos arquivos como o usuário node |
USER node | A imagem oficial já inclui o usuário node, evitando executar como root |
CMD ["node", "server.js"] | O formato exec executa o node diretamente |
.dockerignore:
node_modules
npm-debug.log
dist
.env
.git
Sempre exclua node_modules. O node_modules do host pode conter módulos nativos compilados para outro sistema operacional (Windows, macOS); copiá-los para um contêiner Linux pode causar erros e substituir a versão recém-instalada por npm ci.
Build:
$ 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
Execute e verifique:
$ 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
whoamiretornanode, não root.psmostra que o PID denode server.jsé 1.docker stoplevou apenas 0.15 segundo. O log mostraSIGTERM received, closing server, indicando que o programa recebeu o sinal corretamente e encerrou por conta própria.
Teste prático: por que docker stop leva 10 segundos
Remova as linhas que tratam SIGTERM em server.js e salve como server-nohandler.js. Execute-o com a mesma imagem:
$ 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
Desta vez, docker stop levou 10.13 segundos, e o código de saída foi 137 (128 + 9, indicando que o processo foi encerrado à força por SIGKILL).
Isso acontece por causa de uma regra especial do Linux para o PID 1: sinais para os quais o PID 1 não registrou um manipulador são ignorados. Em um programa comum, a ação padrão ao receber SIGTERM é encerrar. Mas, dentro do contêiner, o node é o PID 1. Se você não escrever process.on("SIGTERM"), o SIGTERM será ignorado. O Docker esperou 10 segundos e, como o processo ainda não havia terminado, só pôde enviar SIGKILL para encerrá-lo à força. As requisições em andamento foram interrompidas.
Há duas soluções:
- Trate SIGTERM no programa, como em
server.jsacima (recomendado, pois permite encerrar com elegância). - Adicione
--init(no Compose, escrevainit: true). Um pequeno processo init integrado ao Docker fica como PID 1 e encaminha os sinais:
$ 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
O ps mostra que o PID 1 passou a ser /sbin/docker-init e que o node passou a ser o PID 7. Como o node não é mais o PID 1, a ação padrão do SIGTERM volta a ser encerrar o processo. docker stop levou apenas 0.22 segundo.
E quanto a npm start?
O Dockerfile.npm é igual ao anterior; só alteramos a última linha para 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
O npm é o PID 1, e o node é um processo filho dele (PID 18). Nos testes, o npm 11 encaminha SIGTERM ao node, por isso ele parou em 0.11 segundo. O log também mostra SIGTERM received.
Ainda assim, recomendamos iniciar diretamente com node:
- Um processo npm a mais fica em execução e consome memória adicional.
- Mensagens do npm aparecem misturadas no log (o
npm noticeno final acima avisa que há uma nova versão). - O encaminhamento de sinais depende da implementação do npm. Pode não ser confiável em versões antigas do npm ou quando há um script shell intermediário.
TypeScript: build em várias etapas
Para projetos que precisam ser compilados (TypeScript, Babel, ferramentas de empacotamento), use build em várias etapas: na primeira etapa, instale todas as dependências e compile; na segunda, instale apenas as dependências de produção e copie o resultado da compilação da primeira etapa.
Projeto de exemplo 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"]
Além de node_modules, .dockerignore também deve excluir dist para evitar que arquivos antigos compilados localmente sejam copiados para a imagem.
$ 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 (clique nas abas para alternar):
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
Pontos principais:
[build 4/6] RUN npm cie[stage-1 4/5] RUN npm ci --omit=devsão executados quase ao mesmo tempo. O BuildKit percebe que as duas etapas não dependem uma da outra e faz o build em paralelo.- A imagem final contém apenas
dist,node_modulese os dois arquivos package; não contémsrcnemtsconfig.json. - A ausência de
node_modules/typescriptindica quedevDependenciesnão foram incluídas na imagem final. - Construa também a primeira etapa com
--target buildpara comparar:ts-app:buildtem 297 MB, enquanto a imagem finalts-app:latesttem 250 MB.
Next.js: saída standalone
O node_modules do Next.js é grande, mas em tempo de execução só uma pequena parte é usada. Ao ativar a saída standalone, o next build analisa os arquivos realmente usados pelo aplicativo e os organiza em .next/standalone, que inclui um node_modules reduzido e o server.js usado para iniciar o servidor.
Projeto de exemplo 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 faz com que a página seja gerada no servidor a cada solicitação, o que permite confirmar que o 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"]
Observações:
.next/standalonenão incluipublicnem.next/static; é preciso copiá-los separadamente, caso contrário a página ficará sem CSS, JS e imagens.HOSTNAME=0.0.0.0faz o Next.js escutar em todas as interfaces de rede; caso contrário, não será possível acessá-lo de fora do contêiner..dockerignoredeve excluir.nextenode_modules.- Se o projeto não tiver uma pasta
public,COPY --from=build /app/publicfalhará. Basta criar uma pastapublicvazia.
$ 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 cienext buildlevaram 14 e 17 segundos, respectivamente, e foram as etapas mais demoradas do build.- No contêiner há apenas
server.js,.next,public,package.jsone onode_modulesreduzido (38.1 MB). - A primeira etapa (com o
node_modulescompleto e o código-fonte) tem 968 MB; a imagem final tem apenas 295 MB, cerca de 53 MB a mais que a imagem basenode:24-alpine(242 MB).
Abra http://localhost:3000:

Ambiente de desenvolvimento: recarregue o código automaticamente
Método 1: monte a pasta do código
compose.yaml:
services:
app:
build: .
command: ["npm", "run", "dev"]
ports:
- "3000:3000"
volumes:
- .:/app
- /app/node_modules
environment:
NODE_ENV: development
commandsubstitui oCMDdo Dockerfile e executanpm run dev, ou seja,node --watch server.js(o modo de monitoramento integrado ao Node.js, que reinicia o processo automaticamente quando os arquivos mudam)..:/appmonta a pasta local em/appno contêiner.- /app/node_modulesé uma técnica comum: usa um volume anônimo para “ocultar”/app/node_modules. Caso contrário,.:/appfaria o contêiner ver a pasta local, que não temnode_modules(ou até tem, mas para outro sistema operacional), e o programa não encontraria 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"}
Edite a mensagem em server.js para Hello after edit e salve, sem precisar reconstruir a imagem:
$ 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
Nos logs, aparecem nesta ordem Change detected in '/app/server.js', Restarting 'server.js' e SIGTERM received, que escrevemos: node --watch também usa SIGTERM para avisar o processo antigo que deve encerrar. O último comando ls node_modules confirma que o contêiner está usando os pacotes instalados na imagem.
Se o projeto tiver devDependencies (como nodemon ou TypeScript), a imagem de desenvolvimento não pode usar --omit=dev. Você pode adicionar target: build em build para especificar que quer usar o primeiro estágio do build em várias etapas.
Método 2: docker compose watch
O recurso watch do Compose não usa montagem. Ele detecta alterações nos arquivos locais e os copia automaticamente para dentro do contêiner (sync) ou reconstrói a imagem (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
- Alterou o código →
synccopia os arquivos para o contêiner, enode --watchdetecta a alteração e reinicia. - Alterou
package.json→rebuildreconstrói a imagem (executanpm cinovamente) e recria o contêiner.
$ docker compose watch
... (output trimmed)
Container express-app-app-1 Started
Watch enabled
Syncing service "app" after 2 changes were detected
Depois de salvar, o Compose exibe Syncing service "app" after 2 changes were detected (ao salvar no editor, muitas vezes são gerados vários eventos de arquivo). Faça a solicitação novamente para ver o novo conteúdo. Pressione Ctrl+C para encerrar o watch.
A vantagem do watch é que ele não é afetado pelas diferenças entre os sistemas de arquivos do host e do contêiner. Ele é mais confiável no Windows e no macOS, e você não precisa do truque de /app/node_modules.
Solução de problemas
Alterações nos arquivos não são detectadas no macOS / Windows
Ao usar montagens, em alguns ambientes os eventos de monitoramento de arquivos não chegam ao contêiner. Você pode usar docker compose watch, mencionado acima, ou configurar a ferramenta de desenvolvimento para usar polling, por exemplo, definindo as variáveis de ambiente CHOKIDAR_USEPOLLING=true e WATCHPACK_POLLING=true. No Windows, você também pode colocar o projeto no sistema de arquivos do WSL 2.
npm ci está lento
Verifique se .dockerignore exclui node_modules e copie package*.json antes de instalar. Assim, essa camada poderá usar o cache quando você alterar somente o código. Você também pode adicionar uma montagem de cache do BuildKit para compartilhar os pacotes baixados pelo npm entre várias builds:
RUN --mount=type=cache,target=/root/.npm npm ci
npm notice aparece nos logs
npm notice New major version of npm available! é um aviso de atualização do npm e não afeta a execução. Você pode desativá-lo adicionando ENV NPM_CONFIG_UPDATE_NOTIFIER=false ao Dockerfile ou --no-update-notifier ao comando. Se iniciar diretamente com node, essa mensagem não aparecerá.
Resumo
npm ci --omit=dev,USER nodeeCMD ["node", ...]no formato exec.- Quando node é o PID 1, ele precisa tratar SIGTERM por conta própria, ou você pode usar
--init. - Para TypeScript e Next.js, use builds em vários estágios e coloque na imagem final somente os arquivos necessários para a execução.
- Durante o desenvolvimento, use montagens +
node --watchoudocker compose watch.
Exercícios
Exercício 1: Inicie server-nohandler.js com docker run --init. Quanto tempo docker stop leva? Por quê?
Resposta
Cerca de 0.2 segundo. --init faz com que docker-init se torne o PID 1 e encaminhe os sinais. Assim, node deixa de ser o PID 1, e a ação padrão de SIGTERM volta a encerrar o processo.
Exercício 2: O que acontece se a imagem final do Next.js standalone não tiver public e .next/static?
Resposta
A página abre, mas o CSS, o JavaScript e as imagens não carregam. Essas duas pastas não estão em .next/standalone; você precisa copiá-las separadamente com COPY.
Perguntas frequentes
Quando usar npm install e quando usar npm ci?
No Dockerfile, use npm ci: ele instala estritamente de acordo com o package-lock.json, garantindo o mesmo resultado sempre.
A imagem do Next.js está muito grande?
Ative output: "standalone"; nos testes, o tamanho caiu de 968 MB para 295 MB. Veja neste capítulo.
No Windows, depois de montar o código, as alterações nos arquivos não acionam a recarga?
Use docker compose watch ou configure a ferramenta de desenvolvimento para usar polling (CHOKIDAR_USEPOLLING=true, WATCHPACK_POLLING=true).
Atualizado 14 de outubro de 2026