Next.jsのDocker化でつまずく原因は、開発用と本番用で求めるものが正反対だからです。開発中はソースを書き換えた瞬間にブラウザへ反映してほしい。本番では書き換えようのない最小の塊にして配りたい。この2つを1本のDockerfileで兼ねようとすると、ホットリロードが効かないか、イメージが1GB近くまで膨らむかのどちらかに落ちる。この記事では開発用のCompose構成と本番用のマルチステージDockerfileを分けて示し、output: "standalone"によるイメージ削減、環境変数をビルド時と実行時で切り分ける方法、そして公式がMac・WindowsでのDocker開発を勧めていない理由までを扱います。記載内容はNext.js 16.3.1(2026年8月13日公開)時点の公式ドキュメントおよび公式exampleに沿っています。
まとめ
- 開発用と本番用でDockerfileを分ける。開発はソースをマウントしてHMRを効かせ、本番はマルチステージで固める
- 本番のベースイメージは
node:24系にする。node:18は2025年4月30日、node:20は2026年4月30日にサポートが終了済み output: "standalone"を有効にすると.next/standaloneが生成され、本番イメージからnode_modulesの大半を落とせる。公式exampleのREADMEでは約110MB、素の構成の約1GBに対して最大85%減という数字が示されているpublicと.next/staticはstandaloneに自動では入らない。手動コピーを忘れると画像とCSSだけが404になるNEXT_PUBLIC_付きの環境変数はnext build時にバンドルへ焼き込まれる。1つのイメージを検証環境と本番環境へ使い回す構成は、この時点で破綻する- Mac・Windowsで開発コンテナのHMRが遅いのは設定ミスではない。公式ガイドがローカル実行を勧めている
開発用と本番用でDockerfileを分ける理由
Next.jsの公式リポジトリにはwith-docker/with-docker-compose/with-docker-multi-env/with-docker-export-outputの4つのDocker関連exampleが置かれています。このうちwith-docker-composeにはdev.Dockerfileとprod.Dockerfileが別ファイルで用意されています。公式がわざわざ分けている理由は、両者でコンテナに入れるものが逆になるからです。
開発用コンテナに必要なのは、ホスト側のソースコードへの参照とnext devの常駐です。ソースをイメージへ焼き込んでしまうと、1文字直すたびにイメージを再ビルドすることになります。一方の本番用コンテナに必要なのはビルド済みの成果物だけで、TypeScriptのコンパイラもテストコードもESLintも実行時には1バイトも要りません。開発用の作り方をそのまま本番へ流用したイメージが1GBに達するのは、この不要物がまるごと同梱されるからです。
したがって設計方針は次のようになります。開発用はベースイメージにソースをマウントして起動するだけの薄い構成、本番用はビルド環境と実行環境を分離するマルチステージ構成。Dockerのイメージレイヤやビルドキャッシュの仕組みそのものについてはDockerの仕組みを原理から理解するで整理しています。
Docker Composeで開発環境を立てる
compose.yamlと開発用Dockerfileの最小構成
開発用のDockerfileは、依存をインストールしてnext devを起動するだけで足ります。公式のwith-docker-compose/next-app/dev.Dockerfileはベースイメージがnode:20-alpineのままですが、Node.js 20は2026年4月30日にサポートが終了済み。後述の理由で本番側もDebianベースのslimに揃えるため、ここではnode:24-slimに差し替えています。
# dev.Dockerfile
FROM node:24-slim
WORKDIR /app
# 依存の解決だけを先に行い、ソース変更でこのレイヤが無効にならないようにする
COPY package.json package-lock.json* .npmrc* ./
RUN npm ci
# 設定ファイルはマウント対象に含めないので、イメージ側に入れておく
COPY next.config.* tsconfig.json ./
# ソース本体はコピーせず、compose 側でマウントする
EXPOSE 3000
CMD ["npm", "run", "dev"]
Compose側では、ソースディレクトリだけを名指しでマウントします。
# compose.dev.yaml
services:
next-app:
container_name: next-app
build:
context: ./next-app
dockerfile: dev.Dockerfile
env_file:
- .env
volumes:
- ./next-app/src:/app/src
- ./next-app/public:/app/public
ports:
- "3000:3000"
restart: always
マウント対象をsrcとpublicに限定しているのが要点です。多くの解説記事はプロジェクト全体を.:/appでマウントし、その結果コンテナ内でnpm ciしたはずのnode_modulesがホスト側の空ディレクトリで覆い隠されます。これを避けるために/app/node_modulesへ匿名ボリュームを重ねる回避策が知られていますが、公式exampleのようにマウント範囲を必要なディレクトリだけに絞れば、その回避策自体が不要になります。
ここで見落としやすいのがnext.configとtsconfig.jsonの扱いです。マウント対象をsrcとpublicに絞ると、この2つはコンテナ内のどこにも存在しなくなります。next devはエラーを出さずに起動し、imagesやrewrites、outputの指定だけが黙って既定値に戻る。公式のdev.DockerfileがCOPY next.config.js .とCOPY tsconfig.json .を明示しているのはこのためで、上のDockerfileでもCOPY next.config.* tsconfig.json ./として取り込んでいます。
起動前に.envを作っておいてください。env_fileで指定したファイルが無いとComposeは起動前に停止します。空ファイルでも構いません。あとはdocker compose -f compose.dev.yaml up --buildで立ち上がります。なおNext.js 16ではnext devの出力先が.nextではなく.next/devに変わり、next devとnext buildの同時実行で衝突しなくなりました。
ホットリロードが効かない・遅いときの確認点
コンテナは起動するのにブラウザが更新されない場合、原因はおおむね次のどれかです。
- 編集しているホスト側のパスが、マウント宣言したパスと一致していない(
appディレクトリを使っているのにsrcだけをマウントしている、など) - ソースをDockerfile側の
COPYで焼き込んでおり、マウントより先にイメージ内のコピーが見えている - ファイル変更イベントがコンテナ境界を越えず、変更が検知されていない
3番目に当たる場合の応急処置がポーリング監視への切り替えです。ただしCPU使用率と引き換えの手当てであり、根本的な解決ではありません。反映が数秒以上かかる状態が続くなら、設定を詰め続けても解決しない可能性があります。公式ガイドがMac・Windowsでの遅延を仕様として説明しているためで、VS Codeで開発コンテナを作る(Create Dev Container)のようなDev Container方式に寄せるか、開発だけホストで走らせる判断へ切り替える分岐点になります。
本番イメージを作るマルチステージDockerfile
依存インストール・ビルド・実行の3ステージ構成
公式のexamples/with-docker/Dockerfileは2026年2月20日の更新(PR #87069)で構成が刷新され、dependencies・builder・runnerの3ステージになりました。パッケージマネージャの分岐を省いてnpmに絞ると次の形です。
# syntax=docker/dockerfile:1
ARG NODE_VERSION=24.13.0-slim
# ---- 1. 依存インストール ----
FROM node:${NODE_VERSION} AS dependencies
WORKDIR /app
COPY package.json package-lock.json .npmrc* ./
RUN --mount=type=cache,target=/root/.npm \
npm ci --no-audit --no-fund
# ---- 2. ビルド ----
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=dependencies /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
RUN npm run build
# ---- 3. 実行 ----
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
COPY --from=builder --chown=node:node /app/public ./public
RUN mkdir .next && chown node:node .next
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
CMD ["node", "server.js"]
上のDockerfileは、公式版からパッケージマネージャの分岐を削り、.npmrcをコピー対象に足し、先頭に# syntaxディレクティブを置いた版です。# syntax行は現行の公式Dockerfileには無いものですが、--mount=type=cacheを使う以上、Dockerfile構文のバージョンは明示しておくと古いビルド環境での失敗を避けられます。
旧来よく出回っている書き方との差分で、実務上効くのは次の3点です。1つ目は--mount=type=cacheによるnpmキャッシュの永続化で、依存が変わらない再ビルドでのダウンロードを省けます。2つ目はUSER nodeで、公式イメージに最初から存在するuid 1000のnodeユーザーを使うため、addgroupとadduserでnextjsユーザーを自作する手順が丸ごと不要です。3つ目はHOSTNAMEとPORTをENVで与えている点で、CMD HOSTNAME="0.0.0.0" node server.jsのようにシェル形式で渡す書き方はシェルを1プロセス挟むため、コンテナへのシグナルがNode.jsまで届きにくくなります。
なお.dockerignoreを用意せずにこのDockerfileを回すと、COPY . .でホストのnode_modulesと.nextがビルドコンテキストに紛れ込みます。除外対象は公式exampleが参考になる。node_modules/と.next/に加え、テスト、CI設定、.env系、エディタ設定までが列挙されています。個々の命令の意味はDockerfileとは|書き方・主要命令・ベストプラクティスを解説にまとめています。
ビルドと起動は2行です。
docker build -t my-next-app .
docker run --rm -p 3000:3000 --env-file .env.production my-next-app
--env-fileで渡した値は実行時にprocess.envへ入りますが、NEXT_PUBLIC_付きの変数だけは例外です。理由は後述します。ここまで通ったイメージにタグを付けてレジストリへdocker pushすれば、Kubernetesでも各社のコンテナ実行基盤でもそのまま動きます。
ベースイメージの選定とNode.jsのサポート期限
ベースイメージの世代は、そのまま脆弱性修正の受け取り可否になります。Node.jsのリリース日程は公式に公開されており、サポート終了日は次のとおりです。
| 系統 | コードネーム | LTS入り | サポート終了 |
|---|---|---|---|
| Node.js 18 | Hydrogen | 2022-10-25 | 2025-04-30(終了済み) |
| Node.js 20 | Iron | 2023-10-24 | 2026-04-30(終了済み) |
| Node.js 22 | Jod | 2024-10-29 | 2027-04-30 |
| Node.js 24 | Krypton | 2025-10-28 | 2028-04-30 |
| Node.js 26 | – | 2026-10-28(予定) | 2029-04-30 |
2026年8月時点で新規に選ぶなら24系です。22系はすでにメンテナンスフェーズに入っており、新機能の取り込みはありません。ネット上のNext.js×Dockerの解説で頻出するnode:18-alpineは、記述時点では妥当だったとしてもサポート終了から1年以上が経過しています。
タグの末尾については、公式exampleが-alpineから-slimへ移っている点が判断材料になります。with-dockerのDockerfileは2025年9月にnode:20-alpineへ更新されたあと、2026年2月の刷新でnode:24.13.0-slimになりました。alpineはmuslベースのため、glibc前提のネイティブモジュールを含むと動かない、あるいは実行時に追加パッケージが要る場合があります。イメージサイズを削るためにビルドの再現性を落とすくらいなら、Debianベースのslimを選ぶ判断が妥当です。ただしパッチまで固定する書き方をそのまま踏襲する場合は、更新を忘れない運用とセットにしてください。公式Dockerfileも冒頭のコメントで、セキュリティと互換性のためにNODE_VERSIONを定期的に最新LTSへ上げるよう明記しています。実際、example側の24.13.0に対して2026年8月時点の最新は24.19.0です。
standaloneモードでイメージを最小化する
standaloneが出力するものとpublic・staticの手動コピー
イメージが小さくなる仕組みの中核はoutput: "standalone"です。設定は1行で済みます。
// next.config.ts
const nextConfig = {
output: "standalone",
};
export default nextConfig;
この指定があるとnext build時にNext.jsが各ページの依存を静的解析し、本番実行に必要なファイルだけを.next/standaloneへ集めます。対象はnode_modules全体ではなく実際に参照されるファイルのみ。最終イメージでnpm ci --omit=devを走らせる工程そのものが不要になります。同時に出力される最小限のserver.jsが、next startの代わりになります。
落とし穴は公式ドキュメントが明記している次の点です。server.jsはpublicと.next/staticを既定ではコピーしません。本来CDNに置くべきものという設計判断によるもので、コンテナ単体で配信したい場合は自分でコピーする必要があります。上のDockerfileでpublicと.next/staticを個別にCOPYしているのはこのためです。ここを落とすと、HTMLは返るのにCSSと画像だけが404になり、原因がDockerfileにあると気づくまで時間を取られます。
ローカルで挙動だけ確かめたい場合は、ビルド後に次を実行すれば同じ状態を再現できます。
cp -r public .next/standalone/
cp -r .next/static .next/standalone/.next/
node .next/standalone/server.js
削減幅の目安と測り方
公式のwith-docker-composeのREADMEは、マルチステージビルドとstandaloneの併用で最大85%小さくなるとし、具体値として最終イメージ約110MB、マルチステージを使わない構成で約1GBという比較を挙げています。ただしこれはcreate-next-appの初期状態を基準にした数字です。Prismaのようにネイティブバイナリを同梱する依存があれば数字は変わりますし、sharpを含む構成でも増えます。自分のプロジェクトでの実測は次の1行で取れます。
docker images --format "{{.Repository}}:{{.Tag}}\t{{.Size}}"
どのレイヤが太っているかまで見るならdocker historyでレイヤ単位のサイズを確認します。削減後のイメージをどこへ置くかによって転送コストの条件も変わるため、保管先を検討する段階ではGitHub Container Registry(ghcr.io)の料金とは|無料枠・課金条件とDocker Hub/ECR比較も判断材料になります。
環境変数をビルド時と実行時で切り分ける
Docker化したNext.jsで最も再現しにくい不具合が、環境変数まわりです。仕様上、NEXT_PUBLIC_を付けた変数はブラウザへ届けるためにnext buildの時点でJavaScriptバンドルへ直接埋め込まれます。公式ドキュメントは、ビルド後のアプリはこれらの変数の変更に反応しないと明記し、1つのDockerイメージを複数環境へデプロイする場合はビルド時の値で凍結されると警告しています。
つまり「検証環境で動作確認したイメージをそのまま本番へ昇格させる」という、コンテナ運用では標準的な手順が、NEXT_PUBLIC_を使った瞬間に成立しなくなります。選択肢は3つありますが、優先度は同じではありません。
| 方針 | やること | 向く場面 |
|---|---|---|
| 環境ごとにビルド | 環境の数だけイメージを作る | 環境が2〜3個で固定 |
| 実行時に配信 | サーバー側で値を返すAPIを用意しクライアントが取得 | 1イメージを多環境へ昇格させたい |
| サーバー側に閉じる | 接頭辞を外しサーバー側で読む | ブラウザに出す必然性がない値 |
公開しなくてよい値をNEXT_PUBLIC_にしているだけのケースが実際には多く、その場合は3番目が最短です。接頭辞のない環境変数はサーバー側にとどまり、コンテナ起動時の値がそのまま効きます。どの値がサーバーに閉じるかはレンダリング方式の理解が前提になるため、Next.jsのレンダリング方式|SSG・ISR・SSR・CSRの違いとApp Routerでの選び方もあわせて確認してください。なお環境ごとにビルドを分ける方針は、公式のexamples/with-docker-multi-envが実装例を示しています。仕組みは、環境別に用意した.env.development.sample/.env.staging.sample/.env.production.sampleを、環境ごとのDockerfileがCOPY .env.production.sample .env.productionのように名前を変えて取り込み、Makefileがdocker/production/compose.yamlなど環境別のcompose定義を切り替えるというものです。
Mac・WindowsでDocker開発を選ぶべきでない場面
ここまで開発用Composeの構成を書いてきましたが、公式の見解は「開発はDockerを使わないほうがよい」です。Next.jsのローカル開発ガイドには「8. Consider local development over Docker」という項目があり、Mac・WindowsでDockerを使うとローカル実行に比べて著しく遅くなる場合があること、同じアプリがローカルでは高速にHMRするのにDocker経由ではHMRに数秒から数分かかることが明記されています。原因はDockerがLinux以外の環境でファイルシステム操作を扱う方式にあり、設定の詰めで解消する類のものではありません。
公式が挙げる指針は3項目あります。開発にはnpm run devを使うこと、Dockerは本番デプロイと本番ビルドの検証に取っておくこと、どうしても開発でDockerが必要ならLinuxマシンまたはVM上で動かすこと。
この記事の立場も同じです。チーム全員のOSがLinuxでないなら、開発コンテナ化の目的を「Node.jsを各自の端末に入れさせないこと」ではなく「Next.js以外の依存を揃えること」に置くべきです。PostgreSQLやRedis、メール受信用のダミーサーバーはComposeで立てて共有し、Next.js自体はホストのnpm run devで動かす。この分担ならHMRの速度は落ちず、環境差の大半は解消します。Next.js本体までコンテナに入れる判断が正当化されるのは、CIと同じ環境でビルドを再現したいとき、Node.jsのバージョン差で実際に事故が起きているとき、そして開発端末がLinuxのときです。next devとnext buildの双方でTurbopackが既定になった16系ではビルド時間の条件そのものが変わっているため、判断の前提としてNext.js 16.3とは?Turbopack永続キャッシュとInstant Navigationsなど新機能を実装者目線で解説も確認してください。
よくある質問
Next.jsのDockerイメージはどのくらい小さくなりますか?
公式exampleの基準では約110MBです(本文「削減幅の目安と測り方」を参照)。ただしこの数字を自分の構成に当てはめる前に、削減の内訳を分けて考えてください。マルチステージ化で減るのはビルドツールとdevDependencies、standaloneで減るのは本番node_modulesのうち実際には読み込まれない部分です。前者だけを導入してnode_modulesをまるごとコピーしている構成は、マルチステージにしてもさほど小さくなりません。sharpやPrismaのようにネイティブバイナリを持つ依存はstandaloneでも残るため、削減率は公式の数値より低く出ます。
Dockerでnpm installはどう書くべきですか?
本番イメージのビルドではnpm installではなくnpm ciを使います。npm ciはpackage-lock.jsonのとおりに導入するため、ビルドのたびに依存の解決結果が変わりません。公式exampleもnpm ci --no-audit --no-fundで、監査とスポンサー表示を止めてビルド時間を削っています。package.jsonとpackage-lock.jsonのコピーをソース全体のCOPYより前に置くのも定石で、ソース変更だけならインストール層のキャッシュがそのまま使われます。
node:18-alpineのままでも動きますか?
起動自体はします。ただしNode.js 18はサポート終了済みで脆弱性修正が提供されないため、本番運用には使えません(終了日は本文の表を参照)。移行時に引っかかりやすいのはalpineからslimへ変えたときの差で、apk addで入れていたパッケージはapt-get installに、libc6-compatのようなmusl向けの回避策は不要になります。ベースイメージだけを差し替える場合、package-lock.jsonにmusl向けのプラットフォーム別依存しか記録されていないと、npm ciでglibc向けのバイナリが入らないことがあります。切り替え時は一度ロックファイルを作り直してください。
NEXT_PUBLIC_の値をコンテナ起動時に差し替えられますか?
できません(理由と対処は本文「環境変数をビルド時と実行時で切り分ける」を参照)。判別に迷ったときは、ビルド後の成果物を直接検索すると確実です。grep -r "元の値" .next/static でヒットすればバンドルに焼き込まれており、起動時の環境変数では上書きできません。動的なキー参照(process.env[varName]のような書き方)はインライン化の対象外になるため、公式ドキュメントもこの形では値が置換されないと注意しています。
next startとstandaloneのserver.jsはどちらを使いますか?
コンテナで動かすならstandaloneのserver.jsです。next startはnode_modulesが揃っている前提で動くため、依存一式をイメージに残すことになります。判断が分かれるのはnext.configを実行時に読ませたい場合で、standaloneのserver.jsはビルド時点の設定を取り込んだ状態で出力されます。起動オプションで挙動を変えたいなら、next.configではなく環境変数側に寄せてください。ポートとホスト名はPORTとHOSTNAMEで外から与えられます。