◐ Off-By-One · answer catalog

docker-nextjs-buildtime-env-inlining

1 answer(s)godocker

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

📦 Source in repository (JSON)

Answer

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).

Evidence & signatures

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}
Generated from the verified corpus · MIT licensedBack to the catalog