目次

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=devdevDependencies をインストールしません
npm cache clean --forcenpm のダウンロードキャッシュを削除し、イメージ内に残しません
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 には、別の OS(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 を送って強制終了します。その結果、処理中のリクエストは中断されます。

解決方法は 2 つあります。

  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 プロセスが 1 つ常駐し、余分なメモリを消費します。
  • ログに npm のメッセージが混ざります(上記の最後にある npm notice は、新しいバージョンがあることを npm が知らせています)。
  • シグナルの転送は npm の実装に依存します。古い npm やシェルスクリプトでラップした場合、確実に転送されるとは限りません。

TypeScript:マルチステージビルド

コンパイルが必要なプロジェクト(TypeScript、Babel、バンドラー)では、マルチステージビルドを使います。第 1 ステージですべてのパッケージをインストールしてコンパイルし、第 2 ステージでは本番環境用のパッケージだけをインストールして、第 1 ステージからコンパイル結果をコピーします。

サンプルプロジェクト 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 は 2 つのステージに依存関係がないことを検出し、並列でビルドします。
  • 最終イメージには dist、node_modules、2 つの package ファイルだけが含まれ、src と tsconfig.json は含まれません。
  • node_modules/typescript が存在しないため、devDependencies が最終イメージに含まれていないことがわかります。
  • --target build を使って第 1 ステージも別途ビルドして比較します。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 が含まれないため、別途コピーする必要があります。コピーしないと、Web ページに 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)だけです。
  • 第 1 ステージ(完全な node_modules とソースコードを含む)は 968 MB ですが、最終イメージは 295 MB です。ベースイメージ node:24-alpine(242 MB)と比べると、約 53 MB 増えただけです。

http://localhost:3000 を開きます:

Docker で実行中の Next.js ページ
Next.js のページ。再読み込みするたびに時刻が変わり、サーバーでリアルタイムに生成されていることがわかります

開発環境:コードを変更すると自動で再読み込み

方法1:コードのフォルダーをマウントする

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 はよく使われるテクニックです。匿名ボリュームで/app/node_modulesを「覆います」。そうしないと、.:/app によってコンテナからローカルのフォルダーが見えるようになりますが、ローカルには node_modules がない(または、あっても別の OS 用のもの)ため、プログラムが 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 を追加すると、マルチステージビルドの最初のステージを指定できます。

方法 2: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
... (build and startup output omitted)
 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 が抜けていると、どうなりますか?

解答

Web ページは開きますが、CSS、JavaScript、画像の読み込みに失敗します。この 2 つのディレクトリは .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日