Next.js を Docker で動かすときは、next.config.ts に output: "standalone" を設定し、ビルドで作られる .next/standalone フォルダーだけを実行用のイメージに入れるのが基本です。standalone は、実行に必要なファイルだけを node_modules から選んでまとめる機能で、node server.js だけで起動できます。

Docker で Next.js を動かす記事は多くありますが、Node.js 18 の頃のものや、root のまま動かす例、HOSTNAME の設定がない例も見かけます。この記事は Next.js 16.3.6 で standalone ビルドを作り、サイズを実測し、実際にコンテナで起動して確かめた結果をもとに書いています。コピペで使える Dockerfile に加えて、NEXT_PUBLIC_ の環境変数が docker run -e で変わらない理由の再現、HOSTNAME がないとヘルスチェックだけ失敗する現象、ISR のキャッシュ書き込みでの権限エラーなど、動かしてみて分かったことを載せています。

Next.js の Docker デプロイ早見表

やりたいこと 設定・書き方
実行に必要なファイルだけをまとめる next.config.ts に output: "standalone"
起動コマンド node server.js(next start ではない)
CSS・JS・画像を配信する .next/static と public を自分でコピーする
コンテナの外から接続できるようにする ENV HOSTNAME=0.0.0.0
ポートを変える ENV PORT=3000(実行時に -e PORT=8080 でも可)
root で動かさない COPY --chown=node:node + USER node
画像の最適化(next/image) sharp は next の依存に含まれ、standalone にも入る
実行時に変えたい設定 NEXT_PUBLIC_ を付けず、サーバーで読む
ブラウザ用の値(NEXT_PUBLIC_) ビルド時に埋め込まれる。--build-arg で渡す
ヘルスチェック /api/health を作り、HEALTHCHECK は node -e "fetch(...)"
複数台で動かす cacheHandler、NEXT_SERVER_ACTIONS_ENCRYPTION_KEY、deploymentId
前に置くプロキシ nginx などのリバースプロキシを推奨。ストリーミングはバッファを切る

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Next.jsをDockerでデプロイ|standaloneで421MB→63MB」

検証環境と、この記事で実行した範囲

この記事は next 16.3.6、react 19.2.8、Node.js 22.22.2、npm 10.9.7、Docker 29.4.3(Linux x86_64)で確認しました。検証環境では Docker Hub からイメージを取得できないため、node:24-slim を使う Dockerfile そのものはビルドしていません。実行した範囲は次のとおりです。

内容 実行したか
next build(standalone あり・なし)とサイズの測定 実行した
.next/standalone の node server.js をホストで起動し、curl・Playwright で確認 実行した
実行ステージを再現したコンテナ(最小イメージにホストの Node.js 22 を入れたもの)での起動、非rootユーザー、HEALTHCHECK、HOSTNAME、権限エラーの確認 実行した
下の「コピペで使える Dockerfile」(node:24-slim の3ステージ)の docker build 未実行(公式の with-docker サンプルに沿って記載)

output: "standalone" で何が変わるか(サイズの実測)

standalone を有効にすると、next build のあとに .next/standalone が作られ、そこに server.js と、実行に必要なものだけを選んだ node_modules が入ります。同じアプリ(ページ5つと API 1つ、next/image を使用)で、実行に必要なファイル一式の大きさを比べました(du --apparent-size で測定)。

構成 必要なもの 大きさ ファイル数
standalone なし(next start) node_modules(npm ci --omit=dev)+ .next(cache を除く)+ public 約421MB 約10,400
standalone あり(node server.js) .next/standalone + .next/static + public 約63MB 約1,290

実行に必要なファイル一式が、約421MB から約63MB(約6.7分の1)になりました。差の大部分は、ビルドにしか使わないコンパイラーです。npm ci --omit=dev の node_modules には、@next/swc-*(約183MB)や next パッケージ全体(約177MB)が入っていましたが、standalone の中の next は約14MB でした。

standalone の中身は次のとおりです。

.next/standalone/
├── server.js            ← 起動用の小さなサーバー
├── package.json
├── .next/               ← サーバー側のビルド結果(BUILD_ID、server/ など)
└── node_modules/        ← 実行に必要なものだけ(next、react、react-dom、sharp、@img など)

sharp(画像変換のライブラリ)は、Next.js 16.3.6 では next の optionalDependencies に含まれており、別途インストールしなくても standalone に入っていました。この検証では @img が約46MB あり、glibc 用と musl 用、wasm 版がすべて含まれていました。

// next.config.ts
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

コピペで使える Dockerfile(Node 24・非root・マルチステージ)

依存のインストール、ビルド、実行の3段階に分けたマルチステージの Dockerfile です。Next.js 公式リポジトリの with-docker サンプル(Node.js 24 の slim イメージ、node ユーザーで実行)の構成に合わせ、npm に絞って短くしています。※ この Dockerfile のビルドは検証環境では未実行です。

# syntax=docker/dockerfile:1
# 本番ではパッチまで固定する(例:24.13.0-slim)。Node 22 なら 22-slim
ARG NODE_VERSION=24-slim

# ---- 1. 依存パッケージのインストール ----
FROM node:${NODE_VERSION} AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ci --no-audit --no-fund

# ---- 2. ビルド ----
FROM node:${NODE_VERSION} AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
# NEXT_PUBLIC_ で始まる値は、この時点で JS に埋め込まれる
ARG NEXT_PUBLIC_API_BASE
ENV NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE}
RUN npm run build

# ---- 3. 実行 ----
FROM node:${NODE_VERSION} AS runner
WORKDIR /app
ENV NODE_ENV=production \
    PORT=3000 \
    HOSTNAME=0.0.0.0
COPY --from=builder --chown=node:node /app/public ./public
COPY --from=builder --chown=node:node /app/.next/standalone ./
COPY --from=builder --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 3000
HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "server.js"]
# .dockerignore
node_modules
.next
.git
*.log
.env*
Dockerfile
.dockerignore
docker build -t my-next-app --build-arg NEXT_PUBLIC_API_BASE=https://api.example.com .
docker run --rm -p 3000:3000 -e APP_MESSAGE=hello my-next-app

各行の意味と、書かなかった場合に起きることです(「実測」は、次の章以降で実際に確認した内容)。

行 役割 書かないと
COPY --from=builder ... .next/standalone ./ server.js と最小限の node_modules を入れる Cannot find module で起動しない
COPY ... .next/static ./.next/static CSS・JS を配信できるようにする 実測:CSS が 404(画面の見た目が崩れる)
COPY ... /app/public ./public 画像などを配信する 実測:/photo.png が 404、/_next/image が 400
ENV HOSTNAME=0.0.0.0 すべてのネットワークで待ち受ける 実測:127.0.0.1 から接続できず unhealthy
--chown=node:node と USER node root 以外で実行する root で動く。--chown だけ忘れると実測:ISR の書き込みで EACCES
HEALTHCHECK コンテナが応答できるかを定期的に確かめる 固まっても「動いている」扱いのまま
.dockerignore の .env* 秘密の値をイメージに入れない .env がイメージに残る

HEALTHCHECK で curl ではなく node -e "fetch(...)" を使っているのは、node:*-slim に curl が入っていないためです。Node.js 18 以降は fetch が標準で使えるので、追加のインストールが要りません。

standalone を手元で起動する(node server.js)

Docker の前に、standalone が単体で動くかをホストで確かめておくと、問題の切り分けが楽になります。公式ドキュメントのとおり、public と .next/static をコピーしてから起動します。

npm run build
cp -r public .next/standalone/ && cp -r .next/static .next/standalone/.next/
cd .next/standalone
PORT=7233 HOSTNAME=0.0.0.0 node server.js
▲ Next.js 16.3.6
- Local:         http://localhost:7233
- Network:       http://0.0.0.0:7233
✓ Ready in 0ms
$ curl -s http://localhost:7233/api/health
{"status":"ok","uptime":4}

$ curl -s -o /dev/null -w "%{http_code} %{content_type} %{size_download}B\n" \
    -H 'Accept: image/webp' "http://localhost:7233/_next/image?url=%2Fphoto.png&w=384&q=75"
200 image/webp 1306B

next/image の最適化も standalone のまま動き、105KB の PNG が 1.3KB の WebP で返りました。比較のため、public と .next/static をコピーしなかった場合の結果です。

css (/_next/static/chunks/….css): 404
/photo.png:                        404
/_next/image?url=%2Fphoto.png…:    400  The requested resource isn't a valid image.

「standalone にしたら CSS が当たらない」「画像が出ない」ときは、まずこの2つのコピーを確認します。公式ドキュメントでは、この2つは本来 CDN から配信するのが望ましいため、既定ではコピーしない、と説明されています。

コンテナで動かして確かめた結果

検証環境では node:24-slim を取得できないため、ローカルにある最小のイメージにホストの Node.js 22 と必要なライブラリを入れ、上の Dockerfile の「3. 実行」ステージと同じ内容を再現しました。COPY する standalone は、ホストでビルドしたものです。

# 検証用(実行ステージの再現)。本番用には前の章の Dockerfile を使う
FROM mini:1
COPY rootfs/ /
WORKDIR /app
ENV NODE_ENV=production \
    PORT=3000 \
    HOSTNAME=0.0.0.0
COPY --chown=1001:1001 app/ ./
USER 1001:1001
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
  CMD ["node", "-e", "fetch('http://127.0.0.1:3000/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"]
CMD ["node", "server.js"]
$ docker run -d --name web -p 7235:3000 -e APP_MESSAGE=from-docker t23-next:verify
$ curl -s http://localhost:7235/api/health
{"status":"ok","uptime":4}
$ docker exec web id
uid=1001(app) gid=1001(app) groups=1001(app)
$ docker ps --format '{{.Names}} {{.Status}}'
web Up 17 seconds (healthy)
確認したこと 結果
起動とヘルスチェック (healthy) になった
実行ユーザー uid=1001(root 以外)
-e APP_MESSAGE=from-docker サーバーで読む値に反映された
docker stop 約0.26秒で停止(終了コード 143 = SIGTERM で終了)

CMD ["node", "server.js"] のように exec 形式で書くと、docker stop の SIGTERM が Node.js に直接届き、すぐに止まりました。CMD node server.js(シェル形式)では間にシェルが入るため、シグナルの届き方が変わります。

HOSTNAME=0.0.0.0 が必要な理由

Docker はコンテナの中に、コンテナ ID と同じ値の環境変数 HOSTNAME を自動で設定します。standalone の server.js はこの HOSTNAME で待ち受けるため、Dockerfile で 0.0.0.0 に上書きしないと、コンテナ ID の名前のアドレスだけで待ち受けます。ENV HOSTNAME=0.0.0.0 の行だけを消したイメージで試した結果です。

$ docker logs web-nohost
▲ Next.js 16.3.6
- Local:         http://0307d9bc1aa9:3000
- Network:       http://0307d9bc1aa9:3000

$ curl -s http://localhost:7236/api/health          ← ポート公開経由
{"status":"ok","uptime":4}

$ docker exec web-nohost node -e "fetch('http://127.0.0.1:3000/api/health')…"
ERR ECONNREFUSED                                     ← コンテナ内の 127.0.0.1

$ docker ps --format '{{.Names}} {{.Status}}'
web-nohost Up 35 seconds (unhealthy)

ポートを公開した外からは応答するのに、127.0.0.1 宛てのヘルスチェックだけが失敗し、unhealthy になりました。「ブラウザでは見えるのにヘルスチェックが通らない」ときは、ログの - Local: がコンテナ ID になっていないかを確認します。

環境変数:NEXT_PUBLIC_ はビルド時に埋め込まれる

Next.js の環境変数は、既定ではサーバーだけで読めます。ブラウザでも使いたい値には NEXT_PUBLIC_ を付けますが、この値は next build の時点で JavaScript に埋め込まれます。同じイメージを開発・本番で使い回したいときに、ここでつまずきやすくなります。

NEXT_PUBLIC_API_BASE=https://build-time.example.com でビルドし、実行時に NEXT_PUBLIC_API_BASE=https://runtime.example.com を渡して Playwright で表示を確かめました。

// app/env/page.tsx
import { connection } from "next/server";
import { ApiBase } from "./ApiBase";

export default async function EnvPage() {
  await connection(); // これ以降はリクエストのたびに実行される(=実行時の環境変数を読める)
  return (
    <main>
      <p id="server">APP_MESSAGE = {process.env.APP_MESSAGE ?? "(未設定)"}</p>
      <ApiBase />
    </main>
  );
}
// app/env/ApiBase.tsx
"use client";

// NEXT_PUBLIC_ で始まる変数は next build の時点の値が JS に埋め込まれる
export function ApiBase() {
  return <p id="public">NEXT_PUBLIC_API_BASE = {process.env.NEXT_PUBLIC_API_BASE ?? "(未設定)"}</p>;
}
画面 ビルド時の値 実行時に渡した値 表示された値
/env の APP_MESSAGE(サーバーで読む・connection() あり) build-time runtime runtime
/env-static の APP_MESSAGE(静的ページ) build-time runtime build-time
/env の NEXT_PUBLIC_API_BASE(Client Component) build-time.example.com runtime.example.com build-time.example.com
/env-runtime の API_BASE(サーバーで読んで props で渡す) build-time.example.com runtime.example.com runtime.example.com

ビルド後の JS ファイル(.next/static/chunks/…js)を検索すると、"https://build-time.example.com" という文字列がそのまま入っていました。NEXT_PUBLIC_ の値は、実行時の -e では変わりません。また、サーバーで読む値でも、ビルド時に HTML を作る静的ページ(○)ではビルド時の値のままでした。

実行時に変えたい値は、NEXT_PUBLIC_ を付けずにサーバーで読み、Client Component には props で渡します。connection() を呼ぶとそのページはリクエストごとに描画されるため、実行時の値が使われます。

// app/env-runtime/page.tsx
import { connection } from "next/server";
import { ApiBaseView } from "./ApiBaseView";

export default async function Page() {
  await connection();
  // NEXT_PUBLIC_ を付けない名前にして、サーバーで読んだ値を props で渡す
  return <ApiBaseView apiBase={process.env.API_BASE ?? "(未設定)"} />;
}
// app/env-runtime/ApiBaseView.tsx
"use client";

export function ApiBaseView({ apiBase }: { apiBase: string }) {
  return <p id="public">API_BASE = {apiBase}</p>;
}
値の種類 渡し方 いつ決まるか
秘密の値(DB のパスワードなど) docker run -e や、オーケストレーターの secret 実行時(サーバーだけで読む)
環境ごとに変わる公開の値 NEXT_PUBLIC_ を付けず、サーバーで読んで props で渡す 実行時
全環境で同じ公開の値 NEXT_PUBLIC_ + --build-arg ビルド時

秘密の値は ARG や --build-arg で渡さないようにします。ビルドの履歴に残る場合があるためです。なお、Next.js 16 では serverRuntimeConfig と publicRuntimeConfig が削除され、環境変数を使う方法に一本化されています。

ヘルスチェック用の API を作る

ロードバランサーや Kubernetes からの死活確認には、軽い Route Handler を1つ用意します。DB など外部への接続は含めず、「プロセスが応答できるか」だけを返すのが一般的です。

// app/api/health/route.ts:コンテナのヘルスチェック用
export function GET() {
  return Response.json({ status: "ok", uptime: Math.round(process.uptime()) });
}

ビルド結果では ƒ /api/health(リクエストごとに実行)になっていました。Next.js 15 以降、Route Handlers の GET は既定でキャッシュされないため、毎回その時点の応答が返ります。

キャッシュの保存先と書き込み権限(ISR)

ISR(一定時間ごとにページを作り直す仕組み)の結果は、既定で各サーバーのメモリ(既定 50MB)と、ディスク上の .next の中に保存されます。export const revalidate = 10 のページを standalone で動かすと、作り直しのたびに .next/server/app/isr.html が書き換わっていました。

そのため、.next は実行ユーザーが書き込めるようにしておく必要があります。COPY から --chown だけを外し(ファイルは root の所有)、USER 1001:1001 で動かしたときのログです。

Failed to update prerender cache for /isr Error: EACCES: permission denied, open '/app/.next/server/app/isr.html'
    at ignore-listed frames {
  errno: -13,
  code: 'EACCES',
  syscall: 'open',
  path: '/app/.next/server/app/isr.html'
}

このとき画面上は新しい内容が表示された(メモリのキャッシュは更新された)ため、エラーに気づきにくい点に注意が必要です。ディスクには保存されないので、再起動すると古い内容に戻ります。--chown を付けた構成では、同じ操作でエラーは出ませんでした。

複数台で動かすときの設定

コンテナを複数台並べる場合、キャッシュ、暗号化キー、ビルドの識別子を全台でそろえる必要があります。公式のセルフホストガイドに書かれている項目をまとめます(この章の設定は検証環境では未実行です)。

項目 何も設定しないと 設定
ISR・データのキャッシュ 台ごとに別々のキャッシュを持つ cacheHandler で Redis などの共有先に保存し、cacheMaxMemorySize: 0
'use cache' のキャッシュ 台ごとのメモリに保存 cacheHandlers と 'use cache: remote'
revalidateTag() 呼んだ台だけが無効化される キャッシュハンドラーに refreshTags() を実装
Server Actions の暗号化キー ビルドごとに別のキー NEXT_SERVER_ACTIONS_ENCRYPTION_KEY を固定(base64、16/24/32バイト)
ビルド ID ビルドのたびに変わる 同じイメージを配る。環境ごとにビルドするなら generateBuildId
入れ替え中の新旧の混在 古い JS を要求して失敗することがある deploymentId で不一致を検出し、ページを再読み込みさせる
// next.config.js(公式ガイドの例)
module.exports = {
  output: "standalone",
  cacheHandler: require.resolve("./cache-handler.js"),
  cacheMaxMemorySize: 0, // 既定のメモリキャッシュを無効にする
  deploymentId: process.env.DEPLOYMENT_VERSION,
};

暗号化キーがそろっていないと、別の台で Server Action を処理したときに「Failed to find Server Action」というエラーになると、公式ガイドに書かれています。

セルフホストで押さえておきたいこと

項目 ポイント
リバースプロキシ nginx などを前に置くことが推奨されている。不正なリクエスト、遅い接続、サイズ制限、レート制限をプロキシ側で処理できる
ストリーミング プロキシやロードバランサーがバッファすると、loading.tsx や部分プリレンダーの効果がなくなる。nginx には X-Accel-Buffering: no
画像の最適化 next start/standalone でそのまま動く。glibc の Linux ではメモリ使用量に追加設定が要る場合がある
after() と停止 SIGTERM を受けると、処理中のリクエストと after() を終えてから止まる。停止の猶予を10〜30秒とることが推奨されている
Proxy(旧 Middleware) next start/standalone でそのまま動く
Cache Components Vercel 専用ではなく、Node.js サーバーや Docker でも動く

よくある失敗と対処

症状 原因 対処
画面は出るが CSS が当たらない .next/static をコピーしていない COPY .next/static ./.next/static
public の画像が 404 public をコピーしていない COPY public ./public
ヘルスチェックだけ失敗する HOSTNAME がコンテナ ID のまま ENV HOSTNAME=0.0.0.0
-e NEXT_PUBLIC_… が反映されない ビルド時に埋め込まれている --build-arg で渡すか、サーバーで読んで props で渡す
サーバーの環境変数が変わらない 静的ページ(○)で読んでいる connection() などで動的にする
ISR のログに EACCES .next が root の所有 COPY --chown=node:node
.next/standalone ができない output: "standalone" がない、または next.config の場所が違う 設定を確認し、ビルドログを見る
モノレポで Cannot find module 追跡の起点がアプリのフォルダー outputFileTracingRoot と outputFileTracingIncludes
台によって表示が違う キャッシュが台ごと cacheHandler で共有、refreshTags()
Failed to find Server Action 台ごとに暗号化キーが違う NEXT_SERVER_ACTIONS_ENCRYPTION_KEY を固定

よくある質問

Q. standalone を使わずに next start で Docker 化してもよいですか? A. 動きますが、node_modules を丸ごと入れる必要があるため、この記事の測定では実行に必要なファイルが約421MB(standalone では約63MB)になりました。特別な理由がなければ standalone をおすすめします。

Q. ベースイメージは alpine と slim のどちらがよいですか? A. 公式サンプルは node:24-slim(Debian 系、glibc)を使っています。alpine(musl)は小さいですが、ネイティブのモジュールで追加の対応が要る場合があります。迷ったら slim から始めると安心です。

Q. .next/cache もイメージに入れるべきですか? A. .next/cache はビルドを速くするためのキャッシュで、実行には必須ではありません。公式サンプルでは、ビルド時の fetch のキャッシュを起動直後から使いたい場合だけコピーする、とコメントで案内されています。

Q. Vercel 以外で使えなくなる機能はありますか? A. 公式のセルフホストガイドでは、画像の最適化、Proxy、ISR、Cache Components、after() などが next start や Docker で動くと説明されています。複数台の構成では、この記事の「複数台で動かすときの設定」を合わせて行います。

まとめ

  • output: "standalone" で、実行に必要なファイル一式が約421MB → 約63MB になった(実測)
  • public と .next/static は自分でコピーする。忘れると CSS や画像が 404
  • ENV HOSTNAME=0.0.0.0 がないと、127.0.0.1 宛てのヘルスチェックが失敗する
  • NEXT_PUBLIC_ はビルド時に埋め込まれる。実行時に変えたい値はサーバーで読んで props で渡す
  • COPY --chown と USER node で root 以外で動かす。.next に書き込めないと ISR で EACCES
  • 複数台ではキャッシュ・暗号化キー・deploymentId をそろえる
  • exec 形式の CMD ["node", "server.js"] で、docker stop のシグナルが直接届く

本記事の内容は、ご自由にお使いください。
ご利用の際は、出典として本ページへのリンクを記載いただけますようお願いします。

(記載例)出典:株式会社RJC「Next.jsをDockerでデプロイ|standaloneで421MB→63MB」

株式会社RJCでは、アプリを作るだけでなく、動かす環境まで含めて仲間と一緒に考え、確かめながら学び合っています。こうした知識を共有しながら、日々技術を磨いています。

株式会社RJC ― SI事業・SES事業・AI駆動開発。RJCは一緒に成長を楽しめる会社です。

WE ARE HIRING

RJCで一緒に開発しながら、
成長を楽しみませんか?

RJCは、Web・モバイル・AIを活用した開発プロジェクトで、テックリードやPM・PMOも活躍するシステム開発会社です。会社を知る、待遇を確かめる、話を聞いてみる。気になるところ見てみてください!

  • 127日年間休日
  • 12時間平均残業時間
  • 毎日ガチャ遊びココロも大切にする福利厚生。アマギフなどの賞品ラインナップ!

ほかにも、チケットレストラン、書籍読み放題、2年ごとの慰労報奨(休暇 or 金一封)、11期連続の黒字決算。

ABOUT RJC RJCがどんな会社か知る 考え方、研修、働き方、福利厚生、社員の前職まで。RJCのことが丸わかり! RJC丸わかりページへ JOB DESCRIPTION 仕事内容・待遇を見てみる 仕事内容、給与・待遇、選考の流れ。経験者も未経験も!応募前に知りたいこと、まとめました! 募集要項を見る ENTRY エントリーする エントリーは1〜2分・履歴書不要です。まずは話を聞いてみたい、という方でも歓迎です! エントリーフォームへ

RJCで一緒に開発しながら、 成長を楽しみませんか?