目錄

用 Docker 部署 Node.js 應用(Express、TypeScript、Next.js)

Dockerfile 入門 用過一個最簡單的 Node.js 範例。這一節整理正式專案應該注意的地方,每個建議都附上實際執行的結果。

Express:專案檔案

express-app/
├── package.json
├── package-lock.json
├── server.js
├── Dockerfile
├── .dockerignore
└── compose.yaml        ← 開發模式才用到

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));
});

最後幾行處理 SIGTERM:收到停止訊號時,先停止接受新連線、等進行中的請求完成,再結束程式。為什麼需要這段,後面會實測。

沒有安裝 Node.js 也能產生 package-lock.json

npm ci 需要 package-lock.json。本機沒有 Node.js 的話,可以用容器產生:

$ 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

對應 Windows 的寫法(點選分頁切換):

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 只產生鎖定檔,不建立 node_modules。

Express:正式環境 Dockerfile

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"]
寫法原因
node:24-alpineNode.js 24 是 LTS(長期支援)版本;alpine 版本體積小。如果有原生模組在 alpine 上編譯失敗,改用 node:24-slim
NODE_ENV=productionExpress 等框架會啟用正式環境的最佳化
npm ci 而不是 npm install嚴格依照 package-lock.json 安裝,每次建置結果相同;鎖定檔與 package.json 不一致時直接失敗
--omit=dev不安裝 devDependencies
npm cache clean --force刪除 npm 的下載快取,不留在映像檔中
COPY --chown=node:node檔案擁有者設為 node 使用者
USER node官方映像檔內建 node 使用者,避免以 root 執行
CMD ["node", "server.js"]exec 格式直接執行 node

.dockerignore:

node_modules
npm-debug.log
dist
.env
.git

一定要排除 node_modules。主機上的 node_modules 可能包含為其他作業系統(Windows、macOS)編譯的原生模組,複製進 Linux 容器會出錯,而且會覆蓋掉 npm ci 剛裝好的版本。

建置:

$ 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

執行並確認:

$ 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 是 node,不是 root。
  • ps 顯示 node server.js 的 PID 是 1。
  • docker stop 只花了 0.15 秒,日誌中看得到 SIGTERM received, closing server,程式有正常收到訊號並自行結束。

實測:為什麼 docker stop 要等 10 秒

把 server.js 中處理 SIGTERM 的那幾行刪掉,另存成 server-nohandler.js,用同一個映像檔執行它:

$ 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

這次 docker stop 花了 10.13 秒,結束代碼是 137(128 + 9,表示被 SIGKILL 強制終止)。

原因是 Linux 對 PID 1 的特殊規則:PID 1 沒有註冊處理函式的訊號會被忽略。一般程式收到 SIGTERM 時,預設動作就是結束;但在容器中 node 是 PID 1,沒有寫 process.on("SIGTERM") 的話,SIGTERM 會被直接忽略。Docker 等了 10 秒還沒結束,只好送出 SIGKILL 強制終止,進行中的請求就被直接中斷。

解法有兩種:

  1. 像前面的 server.js 一樣,在程式中處理 SIGTERM(建議,可以優雅地結束)。
  2. 加上 --init(Compose 寫 init: true),由 Docker 內建的小型 init 程序當 PID 1,負責轉發訊號:
$ 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 看到 PID 1 變成 /sbin/docker-init,node 變成 PID 7。node 不再是 PID 1,SIGTERM 的預設動作恢復成結束程式,docker stop 只花了 0.22 秒。

那 npm start 呢?

Dockerfile.npm 與前面相同,只把最後一行改成 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 是 PID 1,node 是它的子程序(PID 18)。實測 npm 11 會把 SIGTERM 轉發給 node,所以 0.11 秒就停止了,日誌中也看到 SIGTERM received。

不過仍然建議直接用 node 啟動:

  • 多一個 npm 程序常駐,佔用額外的記憶體。
  • 日誌中混進 npm 的訊息(上面最後的 npm notice 就是 npm 在提示有新版本)。
  • 訊號轉發依賴 npm 的實作,舊版 npm 或用 shell 腳本包裝時不一定可靠。

TypeScript:多階段建置

需要編譯的專案(TypeScript、Babel、打包工具)使用 多階段建置:第一階段安裝全部套件並編譯,第二階段只安裝正式環境套件,再從第一階段複製編譯結果。

範例專案 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"]

.dockerignore 除了 node_modules,還要排除 dist,避免本機編譯過的舊檔案被複製進去。

$ 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

對應 Windows 的寫法(點選分頁切換):

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

觀察重點:

  • [build 4/6] RUN npm ci 和 [stage-1 4/5] RUN npm ci --omit=dev 幾乎同時進行。BuildKit 發現兩個階段互不依賴,會平行建置。
  • 最終映像檔只有 dist、node_modules、兩個 package 檔,沒有 src 和 tsconfig.json。
  • node_modules/typescript 不存在,表示 devDependencies 沒有進入最終映像檔。
  • 用 --target build 另外建出第一階段來比較:ts-app:build 是 297 MB,最終的 ts-app:latest 是 250 MB。

Next.js:standalone 輸出

Next.js 的 node_modules 很大,但執行時只用到其中一小部分。開啟 standalone 輸出後,next build 會分析程式實際用到的檔案,整理到 .next/standalone,裡面包含一個精簡的 node_modules 和啟動用的 server.js。

範例專案 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 讓頁面每次請求時才在伺服器產生,用來確認 Node.js 伺服器有在運作):

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"]

注意:

  • .next/standalone 不包含 public 和 .next/static,必須另外複製,否則網頁會缺少 CSS、JS 與圖片。
  • HOSTNAME=0.0.0.0 讓 Next.js 監聽所有網路介面,否則容器外連不進來。
  • .dockerignore 要排除 .next 和 node_modules。
  • 專案沒有 public 資料夾時,COPY --from=build /app/public 會失敗,建立一個空的 public 資料夾即可。
$ 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 和 next build 分別花了 14 秒和 17 秒,是建置中最久的步驟。
  • 容器中只有 server.js、.next、public、package.json 和精簡過的 node_modules(38.1 MB)。
  • 第一階段(包含完整 node_modules 和原始碼)是 968 MB,最終映像檔只有 295 MB,與基礎映像檔 node:24-alpine(242 MB)相比只多了約 53 MB。

打開 http://localhost:3000:

在 Docker 中執行的 Next.js 頁面
Next.js 頁面,每次重新整理時間都會改變,表示是伺服器即時產生的

開發環境:修改程式碼自動重新載入

方法一:掛載程式碼資料夾

compose.yaml:

services:
  app:
    build: .
    command: ["npm", "run", "dev"]
    ports:
      - "3000:3000"
    volumes:
      - .:/app
      - /app/node_modules
    environment:
      NODE_ENV: development
  • command 覆蓋 Dockerfile 的 CMD,改執行 npm run dev,也就是 node --watch server.js(Node.js 內建的監看模式,檔案變動時自動重新啟動)。
  • .:/app 把本機資料夾掛載到容器的 /app。
  • - /app/node_modules 是常見技巧:用一個匿名 Volume 「遮住」/app/node_modules。否則 .:/app 會讓容器看到本機的資料夾,而本機根本沒有 node_modules(或是有,但是給別的作業系統用的),程式就會找不到 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"}

用編輯器把 server.js 的訊息改成 Hello after edit 並存檔,不用重新建置:

$ 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

日誌中依序出現 Change detected in '/app/server.js'、Restarting 'server.js',以及我們自己寫的 SIGTERM received:node --watch 也是用 SIGTERM 通知舊的程序結束。最後的 ls node_modules 確認容器使用的是映像檔中安裝的套件。

如果專案有 devDependencies(例如 nodemon、TypeScript),開發用的映像檔就不能用 --omit=dev。可以在 build 下加 target: build 指定使用多階段建置中的第一階段。

方法二:docker compose watch

Compose 的 watch 功能不使用掛載,而是偵測本機的檔案變動,自動複製到容器內(sync),或重新建置映像檔(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
  • 改程式碼 → sync 把檔案複製進容器,node --watch 偵測到後重新啟動。
  • 改 package.json → rebuild 重新建置映像檔(重新 npm ci)並重建容器。
$ docker compose watch
...(建置與啟動的輸出省略)
 Container express-app-app-1 Started
Watch enabled
Syncing service "app" after 2 changes were detected

存檔後 Compose 顯示 Syncing service "app" after 2 changes were detected(編輯器存檔時常會產生不只一個檔案事件),重新請求就能看到新內容。按 Ctrl+C 結束 watch。

watch 的好處是不受主機與容器之間檔案系統差異的影響,在 Windows 與 macOS 上更可靠,也不需要 /app/node_modules 的技巧。

疑難排解

在 macOS / Windows 上檔案變更沒有被偵測

用掛載方式時,某些環境中檔案監聽事件無法傳進容器。可以改用上面的 docker compose watch,或讓開發工具改用輪詢,例如設定環境變數 CHOKIDAR_USEPOLLING=true、WATCHPACK_POLLING=true。Windows 使用者也可以把專案放在 WSL 2 的檔案系統中。

npm ci 很慢

確認 .dockerignore 有排除 node_modules,並先複製 package*.json 再安裝,只改程式碼時這一層就能使用快取。還可以加上 BuildKit 快取掛載,讓 npm 下載的套件在多次建置之間共用:

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

日誌中出現 npm notice

npm notice New major version of npm available! 是 npm 的更新提醒,不影響執行。可以在 Dockerfile 加上 ENV NPM_CONFIG_UPDATE_NOTIFIER=false 關閉,或在指令加上 --no-update-notifier。直接用 node 啟動就不會有這個訊息。

重點回顧

  • npm ci --omit=dev、USER node、exec 格式的 CMD ["node", ...]。
  • node 是 PID 1 時要自己處理 SIGTERM,或使用 --init。
  • TypeScript 與 Next.js 用多階段建置,只把執行需要的檔案放進最終映像檔。
  • 開發時用掛載 + node --watch,或 docker compose watch。

動手練習

練習 1:把 server-nohandler.js 用 docker run --init 啟動,docker stop 要多久?為什麼?

答案

約 0.2 秒。--init 讓 docker-init 成為 PID 1 並轉發訊號,node 不再是 PID 1,SIGTERM 的預設動作恢復為結束程式。

練習 2:Next.js standalone 的最終映像檔少了 public 和 .next/static 會怎樣?

答案

網頁可以開啟,但 CSS、JavaScript 與圖片會載入失敗。這兩個資料夾不在 .next/standalone 中,必須另外 COPY。

常見問題

npm install 和 npm ci 該用哪個?

Dockerfile 中用 npm ci,它嚴格依照 package-lock.json 安裝,每次結果相同。

Next.js 映像檔太大?

開啟 output: "standalone",實測從 968 MB 降到 295 MB,見本章。

Windows 上掛載程式碼後,修改檔案沒有觸發重新載入?

改用 docker compose watch,或讓開發工具使用輪詢(CHOKIDAR_USEPOLLING=true、WATCHPACK_POLLING=true)。

更新於 2026年10月14日