返回博客

Docker 里容器 env 明明是对的,客户端 URL 却死活不变——Next.js NEXT_PUBLIC_* 必须是构建参数

把 Next.js 应用打进 Docker 镜像部署,切换目标环境时最让人崩溃的场景之一:docker exec 进容器,env 命令打出来的 NEXT_PUBLIC_FOO 完全正确;curl 一个服务端路由,日志也打出了正确值;唯独打开浏览器,页面上的按钮 href、图片地址、API Base 依然指向旧环境的 URL,怎么重启容器都不变。

这不是 Docker 缓存层的问题,也不是 env_file 语法写错了——是 Next.js 的构建机制决定的。

现象

典型症状三件套:

  1. docker exec <容器> env | grep NEXT_PUBLIC_FOO → 打印出正确值 ✓
  2. 服务端中间件 / API Route 读 process.env.NEXT_PUBLIC_FOO → 日志正确 ✓
  3. 浏览器 DevTools 审查客户端渲染的 HTML → 仍是旧环境 URL 或代码里的 || 兜底值 ✗

服务端和客户端读同一个变量,一个对、一个错。这种不对称本身就是诊断签名。

根因

Next.js 的 NEXT_PUBLIC_* 前缀变量在执行 next build 的那一刻就被静态内联进客户端 bundle(JS chunk)。打包完成后,这些值就冻结在镜像文件系统里了,与容器启动时注入的任何环境变量无关。

服务端代码(middleware.ts、Server Component、API Route)在请求时由 node server.js 执行,能正常读到运行时的 process.env,所以服务端看到的是对的。客户端 bundle 是一堆静态 JS 文件,已经完成字符串替换,运行时环境变量对它毫无意义。

坑:docker-compose env_file / environment 改不到客户端 bundle

多阶段 Dockerfile 的 builder 阶段执行 npm run build,此时如果没有通过 ARG + ENVNEXT_PUBLIC_* 传进去,变量就是 undefinednext build 会把你代码里的 || 'https://fallback-cdn/...' 兜底值直接烤进 bundle。之后不管 runner 阶段或 docker-compose 怎么注入,客户端 bundle 已经是只读的静态资产。

正解

在 Dockerfile 的 builder 阶段 RUN npm run build 之前,为每个 NEXT_PUBLIC_* 变量声明 ARG 并转 ENV

FROM node:20-bookworm-slim AS builder
WORKDIR /app
 
# NEXT_PUBLIC_* 必须在 next build 前到位,才能内联进客户端 bundle
ARG NEXT_PUBLIC_API_BASE
ARG NEXT_PUBLIC_DOWNLOAD_URL
ENV NEXT_PUBLIC_API_BASE=${NEXT_PUBLIC_API_BASE}
ENV NEXT_PUBLIC_DOWNLOAD_URL=${NEXT_PUBLIC_DOWNLOAD_URL}
 
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

构建镜像时通过 --build-arg 传入实际值:

docker build \
  --build-arg NEXT_PUBLIC_API_BASE=http://192.168.1.10/api \
  --build-arg NEXT_PUBLIC_DOWNLOAD_URL=http://192.168.1.10/app.dmg \
  -t myapp:staging .

构建完成后验证 bundle 是否已经烤入正确值:

docker run --rm --entrypoint sh myapp:staging -c \
  'grep -roE "http[s]?://[^\"]*download[^\"]*" /app/.next/static | head'

如果还是兜底值,加 --no-cache 强制重建,排除 Docker 层缓存干扰。

正解:每个部署目标单独构建一个镜像,NEXT_PUBLIC_* 烤进去

"一个镜像多环境注入"的模式适用于服务端变量,不适用于 NEXT_PUBLIC_*。如果你需要客户端 URL 随环境变化,唯一正确的方式是:每个环境各自 docker build --build-arg NEXT_PUBLIC_FOO=<该环境的值>,产出独立 tag 的镜像。

排查

不确定是不是这个问题时,用三条命令快速确认:

# 1. 容器 env 是否正确(通常是对的)
docker exec <容器> env | grep NEXT_PUBLIC_FOO
 
# 2. 服务端是否读到正确值(通常也是对的)
curl -sS http://<host>/<某个会打日志的服务端路>
 
# 3. 客户端 HTML 里烤的是什么
curl -sS http://<host>/<客户端渲染页> | grep -oE 'https?://[^"]*'

前两条对、第三条错 → 就是这个问题,无需继续排查 env_file 语法或容器重启顺序。

附:想要运行时可换,不重建镜像怎么办

对于真正需要"不重建镜像就能换值"的场景,正确姿势是不用 NEXT_PUBLIC_ 前缀,改走服务端:

// middleware.ts 或 server component
const apiBase =
  process.env.API_BASE_INTERNAL   // 运行时 env,服务端可读
  || process.env.NEXT_PUBLIC_API_BASE  // 构建期兜底
  || 'https://default.example.com'

服务端读运行时 env,客户端通过 props / API 拿服务端返回的值,从来不依赖客户端 bundle 里烤的字符串。

一句话外卖

NEXT_PUBLIC_*构建时常量,不是运行时环境变量——凡是需要客户端读到的部署相关值,必须在 docker build --build-arg 时就传入。