COPY --from=deps /app/nodemodules ./nodemodules
Root cause: next build statically replaces every process.env.NEXT_PUBLIC_* reference with its value at compile time, baking it into the minified JS in .next/static/chunks/*.js (and .next/server). The standalone server (node server.js) only serves those compiled bundles. So environment: in docker-compose, docker run -e, or docker compose up --env-file never touches NEXT_PUBLIC_* at runtime — changes appear to deploy but have zero effect. The values must exist before next build runs, injected via build.args.
1. Enable standalone output (next.config.mjs):
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
// Server-side (non-NEXT_PUBLIC_) runtime env still works as normal.
};
export default nextConfig;
2. Dockerfile — multi-stage, ARG → ENV before pnpm build:
# ---- deps: install with lockfile ----
FROM node:20-alpine AS deps
RUN corepack enable
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
# ---- build: NEXT_PUBLIC_* must be present HERE, before `next build` ----
FROM node:20-alpine AS build
RUN corepack enable
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Values arrive as Compose build args; ENV makes them visible to the compiler.
# The ENV lives only in this stage (multi-stage) — it does not leak to runtime.
ARG NEXT_PUBLIC_API_URL=http://api:4000
ARG NEXT_PUBLIC_APP_NAME=myapp
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL \
NEXT_PUBLIC_APP_NAME=$NEXT_PUBLIC_APP_NAME \
NEXT_TELEMETRY_DISABLED=1
RUN pnpm build # runs `next build` — inlines NEXT_PUBLIC_* into the bundles
# ---- runner: standalone output + static assets only ----
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=<ip-address>
CMD ["node", "server.js"]
Note the standalone output does not bundle .next/static or public — both must be copied explicitly, or the browser 404s on CSS/JS/chunks.
3. docker-compose.yml — build.args with ${VAR:-default}:
services:
web:
build:
context: .
dockerfile: Dockerfile
args:
# Compose interpolates from shell env / root .env at `docker compose build` time.
# Default is compose-network-correct: the internal service name + port,
# NOT localhost (inside a container, localhost is the container itself).
NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL:-http://api:4000}"
NEXT_PUBLIC_APP_NAME: "${NEXT_PUBLIC_APP_NAME:-myapp}"
ports:
- "3000:3000"
depends_on:
- api
# ❌ Do NOT put NEXT_PUBLIC_* here — silently ignored (compile-time only).
# ✅ Non-NEXT_PUBLIC_ runtime vars (DB URLs, secrets) belong here as usual.
.env.example (drives Compose interpolation at build):
# Build-time values for Next.js client bundles. Empty ⇒ bake http://api:4000.
NEXT_PUBLIC_API_URL=http://api:4000
NEXT_PUBLIC_APP_NAME=myapp
4. Remove dead NEXT_PUBLIC_* with zero consumers:
rg -l "NEXT_PUBLIC_OLD_ENDPOINT" --glob '!node_modules' --glob '!.next' .
# → no output = zero consumers
Delete the dead var from source, .env.example, Dockerfile ARG/ENV lines, and compose args (examples above assume NEXT_PUBLIC_APP_NAME still has consumers — if not, drop it).
Build with an explicit arg and grep the *image's filesystem* — no runtime env involved:
```bash
docker build -t myapp:check \
--build-arg NEXT_PUBLIC_API_URL=http://api:4000 .
# 1) Positive control — the default IS baked into static chunks:
docker run --rm --entrypoint sh myapp:check -c \
"grep -rl 'http://api:4000' .next/static/chunks/ && \
grep -o 'http://api:4000' .next/static/chunks/*.js | wc -l"
# expected: list of chunk files, count > 0 (e.g. 3)
# 2) Negative control — localhost must NOT be present anywhere:
docker run --rm --entrypoint sh myapp:check -c \
"grep -r 'localhost:4000' .next/ || echo 'NO localhost baked'"
# expected: NO localhost baked
# 3) Prove runtime env is a no-op (the original bug), then re-bake via arg:
docker run --rm -e NEXT_PUBLIC_API_URL=http://changed:9000 \
--entrypoint sh myapp:check -c \
"grep -o 'http://changed:9000' .next/static/chunks/*.js | wc -l"
# expected: 0 — runtime env cannot modify compiled bundles
docker build -t myapp:check --build-arg NEXT_PUBLIC_API_URL=http://changed:9000 .
docker run --rm --entrypoint sh myapp:check -c \
"grep -o 'http://changed:9000' .next/static/chunks/*.js | wc -l"
# expected: > 0 — build args are the only path that works
# 4) Compose wiring — confirm interpolation and defaults resolve:
docker compose config | grep -A2 'args:'
# expected: NEXT_PUBLIC_API_URL: http://api:4000 (default when var unset)
# 5) Dead var sweep — zero-consumer check (exit 1 / empty output = clean):
rg "NEXT_PUBLIC_OLD_ENDPOINT" . --glob '!node_modules' --glob '!.next' || echo "no consumers"
```
Edge cases tested/handled:
- **`docker compose up --build` cache:** changing `ARG` changes the resolved `ENV` instruction value, which invalidates the `RUN pnpm build` layer's cache key automatically; no `--no-cache` required. For safety, deploy with `docker compose up --build --force-recreate web`.
- **Zero-JS page:** if an app has no client chunks (`.next/static/chunks` empty), grep `.next/server` as well; URLs are string literals in minified output, so plain `grep -o` is sufficient.
- **Dynamic access is NOT inlined:** only static member access `process.env.NEXT_PUBLIC_X` is replaced by the compiler. `process.env['NEXT_PUBLIC_X']` or destructuring at runtime silently stays `undefined` — use static access everywhere.
- **Special characters:** values containing `$` must be escaped (`$$`) in compose interpolation; quoted scalars (`"${VAR:-http://api:4000}"`) avoid YAML colon parsing issues.
- **Secrets hygiene:** multi-stage Dockerfile keeps build-time `ENV` out of the runtime image (`docker history` / `docker inspect` show no `NEXT_PUBLIC_*` in the runner stage), so public URLs don't linger in runtime config — only in the compiled JS where they're needed.
- **`localhost` trap:** the default is `http://api:4000` (Compose network DNS for the `api` service). `http://localhost:4000` inside the web container points at the web container itself — the classic "silently broken" default.{"model": "deepseek-v4-flash", "problem_class": "docker-nextjs-buildtime-env-inlining", "result": "passed", "tests": 5}