Docker in Practice #3: React/Next.js Build Containers — standalone and the NEXT_PUBLIC Place

8 min read

If #1 FastAPI and #2 Django + DB were two backend shapes, this post is the frontend. Backend and frontend containerization differ in texture — the build artifact (.next, dist/) matters more than a server, and some env vars get baked at build time.

This post in Docker in Practice:

Assume you’re containerizing an app close to what came out of the Next.js blog series. The last section also covers hosting a Vite-style SPA statically with nginx.

Frontend containerization texture #

The texture first.

Backend (FastAPI/Django)Frontend (Next.js/Vite)
Runtime artifactInterpreter + code + venvBuild artifact + (optional) Node
DependenciesPresent at runtimeMostly build-time only (devDeps are large)
Env varsRead at runtimeSome are baked at build time
Container’s roleAPI server(a) Next server or (b) static file server

The second and third rows are the decisive ones.

  • devDependencies — TypeScript, ESLint, Tailwind, build tools. Needed at build, useless at runtime. Multi-stage is essentially required.
  • Build-time env varsNEXT_PUBLIC_*, Vite’s VITE_*. Statically substituted at build, so the same image can’t be reused per environment. Worth pinning down.

Next.js standalone output — making the build container-friendly #

Next.js’s default build needs the whole node_modules. To slim images, turn on standalone in next.config.ts.

next.config.ts
import type { NextConfig } from 'next';

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

export default config;

When on, next build traces only the truly needed dependencies into .next/standalone/. Not the whole node_modules — just files actually imported.

After build:

Build artifacts
.next/
├── standalone/
│   ├── server.js          # entrypoint
│   ├── node_modules/      # only what was traced
│   └── ...
└── static/                # copy separately
public/                    # copy separately

Bring those three (standalone/, .next/static, public/) into the runtime stage.

Dockerfile — three stages #

The deps → builder → runner standard pattern.

Dockerfile
# ─── 1. deps — install dependencies ──────────
FROM node:22-alpine AS deps

WORKDIR /app

# Per package manager: pnpm recommended. npm/yarn similar.
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable && pnpm install --frozen-lockfile

# ─── 2. builder — build ─────────────────────
FROM node:22-alpine AS builder

WORKDIR /app

COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Receive NEXT_PUBLIC_* via ARG (next section)
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && pnpm build

# ─── 3. runner — run ────────────────────────
FROM node:22-alpine AS runner

WORKDIR /app

RUN addgroup --system --gid 1001 nodejs \
    && adduser --system --uid 1001 nextjs

# Copy only 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"]

Key points:

  • deps stage copies package.json + lockfile first → cache hits when deps don’t change. Code-only edits skip this stage.
  • builder stage pulls node_modules from deps and builds. devDeps need only live this far.
  • runner stage copies only the standalone artifact. No devDeps, no build tools. Resulting image ~150MB (alpine base ~50MB).
  • HOSTNAME=0.0.0.0 makes the Next.js standalone server bind to 0.0.0.0. Skip it and the container isn’t reachable from outside.
  • The standalone entrypoint is node server.js, not npm start (= next start).

.dockerignore — keep the build context from being a nuke #

The most commonly forgotten file in Node projects.

.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

Without node_modules excluded, your host’s massive node_modules ships to the daemon every build — multiple GB sliding into Docker, builds taking 2–3 minutes. First line, every time.

.next too. Your host’s build artifacts inside the container can break next build.

Handling NEXT_PUBLIC — baked at build time #

This is the most common pitfall in frontend containerization.

src/lib/api.ts
const API_URL = process.env.NEXT_PUBLIC_API_URL ?? 'http://localhost:8000';

Env vars prefixed with NEXT_PUBLIC_* are statically substituted at build time. The moment pnpm build runs, process.env.NEXT_PUBLIC_API_URL is replaced with the actual value and frozen into the bundle. Passing -e NEXT_PUBLIC_API_URL=... at runtime has no effect — the bundle already holds the build-time value.

What this implies:

  • “One image for every environment (stage/prod)” doesn’t work. You need a per-environment build.
  • In CI, inject env vars via --build-arg.
Add ARG to builder
FROM node:22-alpine AS builder

WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Injected at build time
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_TELEMETRY_DISABLED=1

RUN corepack enable && pnpm build
Inject at build
docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.prod.example.com \
  -t myapp:prod .

Server-only env vars (no prefix) are fine to pass at runtime — no trap there.

server-only — runtime is OK
// app/api/route.ts
const dbUrl = process.env.DATABASE_URL; // not baked in, read at runtime

Static export option — output: 'export' #

If you don’t use server features (SSR, API routes, ISR) and a static site is enough, static export is lighter than standalone.

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

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

export default config;

pnpm build produces an out/ directory of static HTML/JS/CSS. The container needn’t be Node at all — a static server like nginx is lighter.

Dockerfile — static 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;

    # Static assets — long cache
    location ~* \.(js|css|woff2?|png|jpg|svg|ico)$ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }

    # SPA routing fallback
    location / {
        try_files $uri $uri.html $uri/ /index.html;
    }
}

Why listen 8080 — non-root users can’t open ports below 1024 (#1 non-root).

The /index.html fallback in try_files is for SPA routing. Without it, refreshing on /posts/123 returns 404.

Vite SPA case #

For React or another SPA built with Vite, the shape is nearly identical. Build artifact is dist/ instead, and env vars use VITE_* instead of NEXT_PUBLIC_*.

Vite app
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

For React apps with no need for server components, this shape is smallest and fastest. ~50MB.

Env-var separation strategy #

A recap of which value goes where:

VarNext.jsViteWhere to inject
Exposed to clientNEXT_PUBLIC_*VITE_*Build with --build-arg
Server onlyNo prefix(Vite is client-only)Runtime -e/--env-file
API key / secretNever NEXT_PUBLICNever VITE_Server side only, runtime

The most common mistake: exposing an API key with NEXT_PUBLIC. It ends up in the client bundle, plain to see in browser DevTools → Sources. Effectively published in plaintext.

Bundling with compose — backend + frontend #

Stack the frontend onto the backend setup from #2.

compose.yaml — full stack
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 is what gets passed as --build-arg. Declaring it in compose lets per-environment compose files override it (e.g., compose.prod.yaml with NEXT_PUBLIC_API_URL: https://api.prod.example.com).

One subtle point: the API URL the browser hits must be the host-visible URL, not the container network name (api). The browser doesn’t live on the Docker network.

Common pitfalls #

Image bloated past 1GB — devDeps followed into the runtime stage. Use output: 'standalone' or static export + nginx.

Build succeeds but container start fails with “Cannot find module …” — standalone tracing missed some dynamic import. Usually fixed via serverComponentsExternalPackages or outputFileTracingIncludes.

NEXT_PUBLIC_* doesn’t apply — set only at runtime. Verify it was injected via --build-arg at build time.

Apple Silicon build doesn’t run on cloud (amd64)docker buildx build --platform linux/amd64 .... Same as Advanced #2 multi-arch.

HMR doesn’t work in dev mode — Next sometimes can’t detect fs changes inside containers. Bind-mount the code via compose.override.yaml, and you may need WATCHPACK_POLLING=true to make it work.

Wrap-up #

  • Frontend containerization hinges on devDependencies separation and build-time env vars.
  • Next.js with output: 'standalone' copies only the traced artifact into the runner stage — ~150MB.
  • For static sites, output: 'export' + nginx is lighter — ~50MB. Don’t forget the SPA fallback (try_files ... /index.html).
  • NEXT_PUBLIC_* / VITE_* are statically substituted at build time. Build per-environment images. Inject via --build-arg.
  • Never expose secrets / API keys via NEXT_PUBLIC / VITE_ — they end up in the browser bundle in plaintext.
  • Manage build-time values with build.args in compose. The URL the browser sees is the host URL, not the container network name.

In the next post (#4 Building images in CI) we leave local builds and head to CI. GitHub Actions docker/build-push-action, BuildKit cache (GHA cache), multi-arch builds, build time optimization, and handling secrets.

X