Docker 実戦 #3 React/Next.js ビルドコンテナ — standalone と NEXT_PUBLIC の扱い
#1 FastAPI と #2 Django + DB がバックエンドの二つの形だったとすれば、この記事はフロントエンド。バックエンドとフロントエンドではコンテナ化の特性が少し違います — ビルド成果物 (.next、dist/) がサーバよりも重要で、環境変数がビルド時に値へ置き換わる場合もあります。
Docker 実戦 でこの記事の位置:
- #1 FastAPI コンテナ化
- #2 Django + PostgreSQL compose
- #3 React/Next.js ビルドコンテナ — standalone と NEXT_PUBLIC の扱い ← この記事
- #4 CI でのイメージビルド — GitHub Actions
- #5 レジストリ push とタグ戦略
- #6 クラウドデプロイ — Fly.io / Railway / ECS
Next.js でブログを作る シリーズの結果に近いアプリを Docker に収めると仮定します。最後の節では Vite のような SPA を nginx で静的ホスティングするオプションも触れます。
フロントエンドコンテナ化の結 #
まず特性を掴んでおきます。
| バックエンド (FastAPI/Django) | フロントエンド (Next.js/Vite) | |
|---|---|---|
| ランタイム成果物 | インタプリタ + コード + venv | ビルド成果物 + (オプション) Node |
| 依存性 | ランタイムにも存在 | ほぼビルド時のみ (devDeps が大きい) |
| 環境変数 | ランタイムに読む | 一部は ビルド時に刻まれる |
| コンテナの役割 | API サーバ | (a) Next サーバまたは (b) 静的ファイルサーバ |
特に二番目と三番目の違いが重要です。
- devDependencies — TypeScript、ESLint、Tailwind、build 道具。ビルドには必要だがランタイムには不要。マルチステージがほぼ必須。
- ビルド時環境変数 —
NEXT_PUBLIC_*、Vite のVITE_*。ビルド時に静的に置換されるので同じイメージを環境ごとに違うように使えません。一度整理しておく価値のあるポイント。
Next.js standalone output — ビルドをコンテナ親和的に #
Next.js のデフォルトビルドは node_modules 全体を持ち歩く必要があります。イメージをスリムにしたいなら next.config.ts で standalone オプションをオンにするのが定石です。
import type { NextConfig } from 'next';
const config: NextConfig = {
output: 'standalone',
};
export default config;オンにすると next build が .next/standalone/ ディレクトリに 本当に必要な依存性だけトレースして 束ねます。node_modules 全体ではなく、実際に import されたファイルだけ。
ビルド後のディレクトリ:
.next/
├── standalone/
│ ├── server.js # エントリーポイント
│ ├── node_modules/ # トレースされた部分だけ
│ └── ...
└── static/ # 別途コピー必要
public/ # 別途コピー必要この三つ (standalone/、.next/static、public/) を runtime stage に持っていけばよい。
Dockerfile — 三 stage #
deps → builder → runner の定石パターン。
# ─── 1. deps — 依存性インストール ────────────
FROM node:22-alpine AS deps
WORKDIR /app
# パッケージマネージャごとに: pnpm 推奨。npm/yarn も似ている
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
# ─── 2. builder — ビルド ────────────────────
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# NEXT_PUBLIC_* が必要なら ARG で受ける (次の節参考)
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && pnpm build
# ─── 3. runner — 実行 ───────────────────────
FROM node:22-alpine AS runner
WORKDIR /app
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# standalone + static + public だけコピー
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
ENV NODE_ENV=production \
PORT=3000 \
HOSTNAME=0.0.0.0
EXPOSE 3000
CMD ["node", "server.js"]核心ポイント:
- deps stage は
package.json+ lock ファイルだけ先にコピー → 依存変更がなければキャッシュヒット。コードだけ変わればこの stage は通過。 - builder stage は deps の
node_modulesを持ってきてビルド。devDeps はここまでだけあればよい。 - runner stage は standalone 成果物だけ持ってくる。devDeps もなく、ビルドツールもありません。結果イメージ ~150MB レベル (ベース alpine 自体が ~50MB)。
HOSTNAME=0.0.0.0は Next.js standalone サーバが 0.0.0.0 にバインドするように。漏らすとコンテナの外からアクセスできません。node server.jsが standalone のエントリーポイント。npm start(=next start) ではありません。
.dockerignore — ビルドコンテキストが核爆弾にならないように
#
Node プロジェクトで最も漏らしやすく、事故につながりやすいポイント。
node_modules
.next
.next-build
.git
.env
.env.*
!.env.production.example
npm-debug.log
yarn-debug.log
yarn-error.log
pnpm-debug.log
.DS_Store
*.log
coverage
.vscode
.idea
README.mdnode_modules が抜けるとホストの巨大な node_modules が毎ビルド毎にコンテキストとして送信されます。数 GB が Docker デーモンに流れて、ビルドが 2~3 分かかります。必ず最初の行に。
.next も同じ。ホストのビルド成果物がコンテナに入ると next build が壊れる可能性。
NEXT_PUBLIC の扱い — ビルド時に刻まれる #
ここがフロントエンドコンテナ化で最も落とし穴にはまりやすいポイントです。
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';NEXT_PUBLIC_* プレフィックスが付いた環境変数は ビルド時に静的置換 されます。つまり pnpm build が回る瞬間、process.env.NEXT_PUBLIC_API_URL は実際の値に置き換わったままバンドルに含まれます。ランタイムにコンテナに -e NEXT_PUBLIC_API_URL=... を渡しても効果がありません。バンドルにはビルド時の値が刻まれているからです。
これが意味すること:
- 「一つのイメージを全環境 (stage/prod) で使う」ができません。環境ごとに違うイメージをビルドする必要があります。
- CI でビルドするとき環境変数を
--build-argで注入する必要があります。
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# ビルド時に注入される
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && pnpm builddocker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.prod.example.com \
-t myapp:prod .サーバ側でだけ使う環境変数 (プレフィックスなし) はランタイムに渡してもよい。そこには落とし穴がありません。
// app/api/route.ts
const dbUrl = process.env.DATABASE_URL; // ビルドに刻まれず、ランタイムに読む
静的 export オプション — output: 'export'
#
サーバ機能 (SSR、API routes、ISR) を使わず静的サイトで十分なら standalone の代わりに静的 export がもっと軽い。
import type { NextConfig } from 'next';
const config: NextConfig = {
output: 'export',
};
export default config;pnpm build の結果が out/ ディレクトリに静的 HTML/JS/CSS として落ちます。こうなると、コンテナはあえて Node である必要がありません — nginx のような静的サーバの方が軽いです。
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
RUN pnpm build
FROM nginx:1-alpine AS runner
COPY --from=builder /app/out /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080server {
listen 8080;
server_name _;
root /usr/share/nginx/html;
# 静的アセット — キャッシュ長く
location ~* \.(js|css|woff2?|png|jpg|svg|ico)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
# SPA ルーティングフォールバック
location / {
try_files $uri $uri.html $uri/ /index.html;
}
}listen 8080 にした理由 — 非 root ユーザでも開けるポートだから。1024 未満は開けません (#1 の non-root で触れた点)。
try_files の /index.html フォールバックは SPA ルーティングのため。このフォールバックがないと /posts/123 のようなパスでリロードしたとき 404 になります。
Vite SPA の場合 #
React や他の SPA を Vite で作っているなら形式はほぼ同じです。ビルド成果物が dist/ という点だけ違って、環境変数は NEXT_PUBLIC_* の代わりに VITE_* がビルド時に刻まれます。
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile
COPY . .
ARG VITE_API_URL
ENV VITE_API_URL=$VITE_API_URL
RUN pnpm build
FROM nginx:1-alpine AS runner
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 8080サーバコンポーネントが必要ない React アプリならこの形式が最も小さく速い。結果イメージ ~50MB レベル。
環境変数分離戦略 — 整理 #
ビルド時とランタイムで、どんな値が入るかをもう一度整理:
| 変数 | Next.js | Vite | どこで注入 |
|---|---|---|---|
| クライアントに公開される値 | NEXT_PUBLIC_* | VITE_* | ビルド時 --build-arg |
| サーバでだけ使う値 | プレフィックスなし | (Vite はクライアント専用) | ランタイム -e/--env-file |
| API キーのような秘密 | 絶対 NEXT_PUBLIC 禁止 | 絶対 VITE_ 禁止 | サーバ側でだけ、ランタイム |
最も典型的なミス: API キーを NEXT_PUBLIC で公開。クライアントバンドルに刻まれて、ブラウザ DevTools → Sources でそのまま見えます。平文のまま外部に公開された状態。
compose に一緒に束ねる — バックエンド + フロントエンド #
#2 のバックエンド構図にフロントエンドを追加してみます。
services:
db:
image: postgres:17
env_file: .env
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 5s
retries: 10
api:
build: ./backend
env_file: .env
depends_on:
db:
condition: service_healthy
ports:
- "8000:8000"
web:
build:
context: ./frontend
args:
NEXT_PUBLIC_API_URL: http://localhost:8000
ports:
- "3000:3000"
depends_on:
- api
volumes:
pg-data:build.args がビルド時に --build-arg として入る値。compose でこれを明示しておくと環境ごとの compose ファイルで違う値を渡せます。(例: compose.prod.yaml で NEXT_PUBLIC_API_URL: https://api.prod.example.com)
ブラウザが呼び出す API URL はコンテナネットワーク名 (api) ではなく ホストから見える URL でなければならないという点が微妙。ブラウザは Docker ネットワークの中にいないからです。
よくある落とし穴 #
イメージが 1GB を超えて膨らんでいる — devDeps が runtime stage まで付いてきた。output: 'standalone' または静的 export 後の nginx パターンを使ってください。
ビルドはできたのにコンテナ起動時に “Cannot find module …” — standalone トレースが一部の動的 import を見落としたケース。普通 serverComponentsExternalPackages または outputFileTracingIncludes オプションで解決。
NEXT_PUBLIC_* が適用されない — ランタイムにだけ設定したケース。--build-arg でビルド時に注入したか確認。
Apple Silicon ビルドがクラウド (amd64) で動かない — docker buildx build --platform linux/amd64 ...。この問題は 上級 #2 マルチアーキ と全く同じです。
HMR が動かない (開発モード) — Next はコンテナの中で fs 変更検知がうまくいかない環境があります。compose.override.yaml でコードを bind mount し、WATCHPACK_POLLING=true 環境変数を渡す必要があったり。
まとめ #
- フロントエンドコンテナ化は devDependencies 分離 と ビルド時環境変数 の二つが核心。
- Next.js は
output: 'standalone'でトレースされた成果物だけ runner stage にコピー — イメージ ~150MB。 - 静的サイトなら
output: 'export'+ nginx の方が軽い — イメージ ~50MB。SPA ルーティングフォールバック (try_files ... /index.html) を忘れずに。 NEXT_PUBLIC_*/VITE_*は ビルド時に静的置換 される。環境ごとに違うイメージをビルドする必要。--build-argで注入。- 絶対秘密 / API キーを
NEXT_PUBLIC/VITE_で公開しないこと — ブラウザバンドルに平文で刻まれる。 - compose で
build.argsでビルド時の値を管理。ブラウザが見る URL はコンテナネットワーク名ではなくホスト URL。
次の記事 (#4 CI でのイメージビルド) ではローカルビルドを離れて CI に移ります。GitHub Actions の docker/build-push-action、BuildKit キャッシュ (GHA cache)、マルチアーキビルド、ビルド時間最適化、シークレットの扱いまで見ます。