Docker in Practice #3: React/Next.js Build Containers — standalone and the NEXT_PUBLIC Place
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:
- #1 Containerizing FastAPI
- #2 Django + PostgreSQL compose
- #3 React/Next.js build containers — standalone and handling NEXT_PUBLIC ← this post
- #4 Building images in CI — GitHub Actions
- #5 Registry push and tag strategy
- #6 Cloud deploy — Fly.io / Railway / ECS
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 artifact | Interpreter + code + venv | Build artifact + (optional) Node |
| Dependencies | Present at runtime | Mostly build-time only (devDeps are large) |
| Env vars | Read at runtime | Some are baked at build time |
| Container’s role | API 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 vars —
NEXT_PUBLIC_*, Vite’sVITE_*. 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.
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:
.next/
├── standalone/
│ ├── server.js # entrypoint
│ ├── node_modules/ # only what was traced
│ └── ...
└── static/ # copy separately
public/ # copy separatelyBring those three (standalone/, .next/static, public/) into the runtime stage.
Dockerfile — three stages #
The deps → builder → runner standard pattern.
# ─── 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_modulesfrom 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.0makes 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, notnpm start(=next start).
.dockerignore — keep the build context from being a nuke
#
The most commonly forgotten file in Node projects.
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.mdWithout 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.
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.
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 builddocker 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.
// 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.
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.
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;
# 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_*.
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 8080For 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:
| Var | Next.js | Vite | Where to inject |
|---|---|---|---|
| Exposed to client | NEXT_PUBLIC_* | VITE_* | Build with --build-arg |
| Server only | No prefix | (Vite is client-only) | Runtime -e/--env-file |
| API key / secret | Never NEXT_PUBLIC | Never 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.
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.argsin 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.