Docker 実戦 #3 React/Next.js ビルドコンテナ — standalone と NEXT_PUBLIC の扱い

読了 8分

#1 FastAPI#2 Django + DB がバックエンドの二つの形だったとすれば、この記事はフロントエンド。バックエンドとフロントエンドではコンテナ化の特性が少し違います — ビルド成果物 (.nextdist/) がサーバよりも重要で、環境変数がビルド時に値へ置き換わる場合もあります。

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 オプションをオンにするのが定石です。

next.config.ts
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/staticpublic/) を runtime stage に持っていけばよい。

Dockerfile — 三 stage #

deps → builder → runner の定石パターン。

Dockerfile
# ─── 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 stagepackage.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 プロジェクトで最も漏らしやすく、事故につながりやすいポイント。

.dockerignore
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.md

node_modules が抜けるとホストの巨大な node_modules が毎ビルド毎にコンテキストとして送信されます。数 GB が Docker デーモンに流れて、ビルドが 2~3 分かかります。必ず最初の行に。

.next も同じ。ホストのビルド成果物がコンテナに入ると next build が壊れる可能性。

NEXT_PUBLIC の扱い — ビルド時に刻まれる #

ここがフロントエンドコンテナ化で最も落とし穴にはまりやすいポイントです。

src/lib/api.ts
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 で注入する必要があります。
builder に 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 build
ビルド時注入
docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.prod.example.com \
  -t myapp:prod .

サーバ側でだけ使う環境変数 (プレフィックスなし) はランタイムに渡してもよい。そこには落とし穴がありません。

server-only — ランタイム OK
// app/api/route.ts
const dbUrl = process.env.DATABASE_URL; // ビルドに刻まれず、ランタイムに読む

静的 export オプション — output: 'export' #

サーバ機能 (SSR、API routes、ISR) を使わず静的サイトで十分なら standalone の代わりに静的 export がもっと軽い。

next.config.ts — 静的 export
import type { NextConfig } from 'next';

const config: NextConfig = {
  output: 'export',
};

export default config;

pnpm build の結果が out/ ディレクトリに静的 HTML/JS/CSS として落ちます。こうなると、コンテナはあえて Node である必要がありません — nginx のような静的サーバの方が軽いです。

Dockerfile — 静的 export + 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 8080
nginx.conf
server {
    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_* がビルド時に刻まれます。

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.jsViteどこで注入
クライアントに公開される値NEXT_PUBLIC_*VITE_*ビルド時 --build-arg
サーバでだけ使う値プレフィックスなし(Vite はクライアント専用)ランタイム -e/--env-file
API キーのような秘密絶対 NEXT_PUBLIC 禁止絶対 VITE_ 禁止サーバ側でだけ、ランタイム

最も典型的なミス: API キーを NEXT_PUBLIC で公開。クライアントバンドルに刻まれて、ブラウザ DevTools → Sources でそのまま見えます。平文のまま外部に公開された状態。

compose に一緒に束ねる — バックエンド + フロントエンド #

#2 のバックエンド構図にフロントエンドを追加してみます。

compose.yaml — フルスタック
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.yamlNEXT_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)、マルチアーキビルド、ビルド時間最適化、シークレットの扱いまで見ます。

X