Dockerizing Next.js for production
Dockerizing Next.js for production
Most Dockerfiles for Next.js you’ll find online ship a 1.2 GB image, leak environment variables at build time, and rebuild every layer on a one-line change. They work on the demo. They don’t work in production. 你在网上找到的大多数 Next.js Dockerfile 都会构建出一个 1.2 GB 的镜像,在构建时泄露环境变量,并且在修改一行代码后就会重新构建每一层。它们在演示中能用,但在生产环境中却不行。
This is the Dockerfile I actually run. Multi-stage, ~150 MB final image, build-time and runtime env vars cleanly separated, layer caching that survives a package.json change. I’ll walk through every line, explain why each stage exists, and call out the four gotchas that account for most “it worked locally” production failures. 这是我实际使用的 Dockerfile。它采用多阶段构建,最终镜像大小约为 150 MB,构建时与运行时的环境变量被清晰地分离,且层缓存即使在 package.json 发生变更时依然有效。我将逐行讲解,解释每个阶段存在的原因,并指出导致大多数“本地运行正常但生产环境失败”问题的四个陷阱。
The full setup (Dockerfile plus docker-compose, GitHub Actions deploy pipeline, auth, testing) is in a production-grade Next.js + NestJS starter I’m building. Free for email subscribers — subscribe at mahmoud-mokaddem.com. 完整的配置(Dockerfile 加上 docker-compose、GitHub Actions 部署流水线、身份验证和测试)包含在我正在构建的一个生产级 Next.js + NestJS 启动模板中。邮件订阅者可免费获取——请访问 mahmoud-mokaddem.com 订阅。
The Dockerfile, up front
Dockerfile 概览
If you’re in a hurry, copy this and skip to Common gotchas. The rest of the post explains every line. 如果你赶时间,请直接复制这段代码并跳到“常见陷阱”部分。文章的其余部分将解释每一行代码。
# Stage 1: deps
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# Stage 2: builder
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# Stage 3: runner
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
Three stages: deps, builder, runner. The first two do work; only the third ships. 三个阶段:deps(依赖)、builder(构建)、runner(运行)。前两个阶段负责处理工作,只有第三个阶段会被发布。
Why multi-stage
为什么要使用多阶段构建?
A naïve Dockerfile copies your source, installs dependencies, builds, and runs — all in one stage. The image you ship to production carries everything that helped you build it: the full Node toolchain, npm’s cache, dev dependencies, build artifacts you don’t need at runtime, your .git directory if you weren’t careful with .dockerignore. Easily 1+ GB. 一个简单的 Dockerfile 会在同一个阶段内复制源码、安装依赖、构建并运行。你发布到生产环境的镜像会携带所有辅助构建的工具:完整的 Node 工具链、npm 缓存、开发依赖、运行时不需要的构建产物,如果你没注意 .dockerignore,甚至还会包含 .git 目录。这很容易超过 1 GB。
Multi-stage builds let you do all that work in a “fat” intermediate image, then copy only the artifacts that need to ship into a clean final image. Each FROM starts a fresh image; COPY —from= reaches back into a previous stage to grab specific files. For Next.js, the practical result: ~150 MB final image vs ~1.2 GB single-stage. 多阶段构建允许你在一个“臃肿”的中间镜像中完成所有工作,然后仅将需要发布的产物复制到一个干净的最终镜像中。每个 FROM 指令都会启动一个全新的镜像;COPY —from= 则会回溯到之前的阶段以获取特定文件。对于 Next.js 而言,实际效果是:最终镜像约为 150 MB,而单阶段构建则约为 1.2 GB。
Why this matters in production:
为什么这在生产环境中很重要:
- Faster registry pulls on small VPSes or autoscaling platforms. Pulling 1.2 GB on a 100 Mbps link takes ~96 seconds; pulling 150 MB takes ~12. 在小型 VPS 或自动扩缩容平台上,镜像拉取速度更快。在 100 Mbps 的带宽下,拉取 1.2 GB 需要约 96 秒;而拉取 150 MB 仅需约 12 秒。
- Faster cold starts on platforms like Fly.io and Cloud Run, where containers start on demand. 在 Fly.io 和 Cloud Run 等按需启动容器的平台上,冷启动速度更快。
- Lower registry cost when you push every commit. 当你每次提交都推送镜像时,镜像仓库的存储成本更低。
- Smaller security surface — fewer packages carrying potential CVEs in production. 更小的安全攻击面——生产环境中携带潜在 CVE(漏洞)的包更少。
The mental shortcut: do the messy work in a fat intermediate image, ship only the artifacts that need to run. 思维捷径:在臃肿的中间镜像中完成杂乱的工作,只发布运行所需的产物。
Stage 1 — Dependencies
第一阶段 — 依赖
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
node:20-alpine is a deliberate trade-off. Alpine Linux is ~50 MB; node:20-slim is ~340 MB; node:20 (Debian-based) is ~1 GB. Alpine wins on size and is fine for almost every Next.js app. 选择 node:20-alpine 是一种深思熟虑的权衡。Alpine Linux 约为 50 MB;node:20-slim 约为 340 MB;node:20(基于 Debian)约为 1 GB。Alpine 在体积上胜出,且适用于几乎所有的 Next.js 应用。
The catch: Alpine uses musl libc instead of glibc. Some npm packages with prebuilt native binaries (historically canvas, sharp, certain database drivers) ship glibc binaries that don’t load on Alpine. If you hit a binary-compatibility error during npm ci, the fix is usually to switch this stage’s base to node:20-slim and accept the larger image. For a vanilla Next.js app, you’ll never see this. 陷阱在于:Alpine 使用 musl libc 而不是 glibc。一些带有预构建原生二进制文件的 npm 包(历史上如 canvas、sharp、某些数据库驱动)提供的 glibc 二进制文件无法在 Alpine 上加载。如果你在 npm ci 期间遇到二进制兼容性错误,通常的解决方法是将该阶段的基础镜像切换为 node:20-slim 并接受更大的镜像体积。对于标准的 Next.js 应用,你永远不会遇到这个问题。
Notice we copy only package.json and package-lock.json, not the source. This is layer-caching discipline. Docker caches each layer; if a layer’s input hasn’t changed, it reuses the cached output. By isolating the dependency install to the lockfile, we get full cache reuse on every commit that doesn’t touch dependencies — which is most of them. If we copied the source first, every code change would re-run npm ci from scratch. 注意我们只复制了 package.json 和 package-lock.json,而不是源码。这是层缓存的规范。Docker 会缓存每一层;如果一层的输入没有改变,它就会重用缓存的输出。通过将依赖安装隔离到 lockfile,我们在每次不涉及依赖变更的提交中都能获得完整的缓存重用——这种情况占大多数。如果我们先复制源码,那么每次代码变更都会导致 npm ci 从头开始运行。
About npm ci vs npm install: ci is deterministic, installs exactly what’s in the lockfile, fails if the lockfile is out of date, and is faster. Always ci in Docker. (Yarn: yarn install —frozen-lockfile. pnpm: pnpm install —frozen-lockfile.) 关于 npm ci 与 npm install:ci 是确定性的,它严格安装 lockfile 中的内容,如果 lockfile 过期则会报错,且速度更快。在 Docker 中始终使用 ci。(Yarn 使用:yarn install —frozen-lockfile。pnpm 使用:pnpm install —frozen-lockfile。)
Stage 2 — Builder
第二阶段 — 构建
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
Fresh stage, fresh Alpine, node_modules pulled forward from stage 1. COPY . . brings in the source tree (filtered by .dockerignore, covered below). 全新的阶段,全新的 Alpine,从第一阶段拉取 node_modules。COPY . . 引入了源码树(受 .dockerignore 过滤,下文会提到)。
The standalone output mode is the one Next.js config flag you actually need. Add it to your next.config.js: standalone 输出模式是你真正需要的 Next.js 配置项。将其添加到你的 next.config.js 中:
module.exports = {
output: 'standalone',
};
Without this flag, npm run build produces the standard Next.js build output and your final image has to ship the entire node_modules tree (~300 MB+). With it, Next.js traces every dependency actually used by your built routes and emits a self-contained server.js plus only those traced packages in .next/standalone/node_modules, typically ~15 MB. That one flag is the biggest size win in this Dockerfile. 如果没有这个配置,npm run build 会产生标准的 Next.js 构建输出,你的最终镜像必须包含整个 node_modules 树(约 300 MB+)。有了它,Next.js 会追踪构建路由实际使用的每一个依赖,并生成一个自包含的 server.js,以及 .next/standalone/node_modules 中仅包含那些被追踪到的包,通常约为 15 MB。这一个配置是该 Dockerfile 中体积优化效果最显著的部分。
npm run build produces three things we care about: npm run build 产生我们关心的三样东西:
- .next/standalone/ — the self-contained server plus traced node_modules .next/standalone/ — 自包含的服务器加上追踪到的 node_modules
- .next/static/ — built static assets (JS bundles, CSS) for _next/static/* routes .next/static/ — 为 _next/static/* 路由构建的静态资源(JS 包、CSS)
- public/ — static files you put in the public folder, which Next.js doesn’t bundle into standalone public/ — 你放在 public 文件夹中的静态文件,Next.js 不会将它们打包进 standalone
Stage 3 copies these three things and nothing else. 第三阶段只复制这三样东西,其他什么都不复制。
Build-time vs runtime env vars
构建时与运行时的环境变量
This is the most common Next.js + Docker bug I see, so it gets its own callout. 这是我见过的最常见的 Next.js + Docker 错误,因此我专门提出来说明。
Variables prefixed NEXT_PUBLIC_ are baked into the client-side JavaScript bundle at build time. They are not read at runtime from the container’s environment. If you set NEXT_PUBLIC_API_URL only at runtime via docker run -e, your client code will see whatever value it had at build time (usually empty), not what you set at runtime. 以 NEXT_PUBLIC_ 开头的变量在构建时会被硬编码到客户端 JavaScript 包中。它们不会在运行时从容器的环境变量中读取。如果你仅通过 docker run -e 在运行时设置 NEXT_PUBLIC_API_URL,你的客户端代码看到的将是构建时的值(通常为空),而不是你在运行时设置的值。
Two ways to handle it. (a) Pass NEXT_PUBLIC_* as — 有两种处理方法。(a) 将 NEXT_PUBLIC_* 作为 — 传递。