Dockerfile(deps → builder →
runner);docker-compose.yml 只有 Postgres + app 两个服务;每次发布产出一个
镜像(约 349MB,Next.js standalone server,dumb-init 当 PID 1)。运行时镜像
故意不打包 drizzle-kit——迁移由开发者从本机针对生产 DATABASE_URL 触发。
这样镜像保持精简,滚动发布也不会有竞态风险。
前置条件
- Docker 24+。BuildKit 默认开启,deps 阶段的 pnpm store
--mount=type=cache必须靠它。 - Compose 插件(
docker compose version能打印版本就行)。 - 本地开发就这些——compose 自带 Postgres 17。
- 生产建议上托管 Postgres(Neon、Supabase、RDS、Crunchy、Railway 任选)。 内置的 compose Postgres 只适合开发,没备份没高可用。
快速上手:docker compose
本地栈就两个服务——postgres 和 app。compose 不跑迁移,迁移由你在宿主机用
pnpm db:push(开发)或 pnpm db:migrate(提交了正式迁移文件之后)触发。
/register 注册,搞定。
停服保留数据:docker compose down。彻底清掉 Postgres:docker compose down -v。
开发阶段
pnpm db:push 直接把 src/db/*.schema.ts 同步到本地 DB——不用生成
迁移文件。生产环境永远用 pnpm db:migrate 跑提交进 git 的迁移。加可选 API key
docker-compose.yml 内置默认值只覆盖最低需求(auth + DB)。要启用 Stripe、Resend、
OAuth provider、AI key 等,建一个 .env.docker:
env_file 指令读它(声明了 required: false,文件不存在也不报错)。
.env.docker 里的值会覆盖 environment: 块里的默认。改完用
docker compose up --build 重启生效。.env.docker 既在 .gitignore 也在
.dockerignore 里——不会进镜像,不会进 git。
构建生产镜像
每次发布构建一个镜像:runner target 跑业务流量。NEXT_PUBLIC_* 必须在构建时传入——Next.js 会把这些值
内联到客户端 bundle 里。镜像推到 registry,编排层(K8s manifest、Nomad job、
ECS task 之类)按 tag 引用。
为什么 NEXT_PUBLIC_APP_URL 必须是 build-arg
这个坑第一次踩谁都中招。Next.js 把所有NEXT_PUBLIC_* 值在构建时内联进
客户端 JavaScript bundle——浏览器里它们不是从 process.env 读出来的,而是在打包前
作为字符串字面量替换掉。
如果构建生产镜像时不传 --build-arg NEXT_PUBLIC_APP_URL=https://...,
Dockerfile 里的默认值(http://localhost:3000)就会被烧进发给用户的 JS 里。表现:
- 客户端的 OAuth 重定向 URL 指向
http://localhost:3000/... - 分享 / SEO 元数据里的绝对 URL 都是 localhost
- 任何在客户端组件里读
process.env.NEXT_PUBLIC_APP_URL的地方, 生产环境拿到的都是http://localhost:3000
运行生产 runner
| 变量 | 说明 |
|---|---|
DATABASE_URL | 带连接池的 Postgres URL。托管库加 ?sslmode=require。 |
BETTER_AUTH_SECRET | 32 位以上随机字符。openssl rand -base64 32。 |
BETTER_AUTH_URL | 公网域名。没填 OAuth 回调直接挂。 |
ADMIN_EMAILS | 逗号分隔的 email,注册后自动拿 role=admin。 |
docs/env-reference。
数据库迁移
迁移故意不放在运行时镜像里。runner 只装了服务流量必需的东西——drizzle-kit 在 devDependencies 里,由你从本机调用:
docker run / kubectl apply 滚出依赖新 schema 的镜像之前先跑这一步。
为什么这么搞:
- 运行时镜像更精简——不带
drizzle-kit,不带迁移文件。 - 单副本部署本身就没竞态,schema 什么时候动完全你说了算。
- 比起为这种规模的 scaffold 接 init container 或 one-shot job,认知成本更低。
PaaS 一键部署
Railway、Render、Fly.io、Coolify、Dokploy——都自动识别根目录Dockerfile。
runner target 已经是最后一个阶段,构建直接落到正确镜像上,不需要额外配置。
触动 schema 的发布之前,从本机(或带源码的一次性容器)跑 pnpm db:migrate。
多平台 / Apple Silicon
M 系列 Mac 上构建给 x86 Linux 服务器用,要用buildx:
引擎盖下都改了什么
当前配置里值得知道的几个生产级升级:dumb-init当 PID 1。 正确把 SIGTERM 转发给 Node 进程,docker stop能在约 0.3 秒优雅退出,不用等满 10 秒宽限期再被 SIGKILL。滚动发布的关键。- BuildKit pnpm store 缓存挂载(
--mount=type=cache,id=pnpm-store)。 CI 构建从冷启动 3 分钟降到热缓存 90 秒——pnpm 的内容寻址 store 跨构建复用。 - 原生依赖工具链(
libc6-compat、python3、make、g++)放在 deps 阶段。 避免 Alpine 上某些 npm 包源码编译时npm install失败 (better-sqlite3、sharp 各种变体之类)。 - 构建时占位符
DATABASE_URL和BETTER_AUTH_SECRET。 作用域限制在pnpm build这一行 RUN 里——不会留在镜像层里,避开了 Docker linter 的SecretsUsedInArgOrEnv警告,同时让next build能过 模块级的 Zod(@t3-oss/env-nextjs)校验。 - runner 阶段的
mkdir .next && chown nextjs:nodejs。 提前给非 root 用户 建好缓存目录,prerender / image-optimization 在运行时写入才能成功, 连受限 PSP 的平台也能跑。
常见坑
- 构建时忘传
--build-arg NEXT_PUBLIC_APP_URL。 客户端所有绝对 URL 在生产 全部指向http://localhost:3000。运行时改不了——重新构建。 - 新镜像先于
pnpm db:migrate上线。 新代码可能引用还不存在的字段, 启动时 Zod 或 Drizzle 直接报错。永远先迁移、再发布。 - 把
.env挂载到运行容器的/app/.env。 Next.js 的 standalone server 运行时不读.env文件。env 必须通过docker run -e/--env-file注入, 或者用平台的 secret store。 - Apple Silicon 上不用
buildx直接构建给 amd64 生产用。 M 系列上的普通docker build出来的是 arm64 镜像,在 x86 Linux 主机上起不来。 用上面的多平台方案。