- Next.js
Next.jsをDockerでデプロイ|standaloneで421MB→63MB
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 や画像が 404ENV 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期連続の黒字決算。