
一次 Next.js 自动化部署的完整实践(含 deploy.yml 示例)
这篇文章不是 Docker 教程,也不是 Next.js 入门。
它写给的是像我一样的个人独立开发者:
- 使用 廉价 VPS(1C2G / 2C2G / 2C4G)
- 内存小、CPU 不强
- 同一台机器上往往还跑着 Nginx、Redis、数据库等服务
在这种条件下,继续在 VPS 上执行 Next.js 项目编译,往往不是“优化问题”,而是架构选择错误。这类场景下,把构建流程迁移到 CI,反而是成本最低、稳定性最高的解法。
本文记录的是一次真实项目中,从「VPS 上手动 checkout + 编译」迁移到 GitHub Actions + Docker + 自动部署 的完整复盘。
背景补充:
- 我当前仍然使用 宿主机的 Nginx 做反向代理与 TLS(Nginx 暂未容器化)。
- 应用容器的端口只绑定到
127.0.0.1,由宿主机 Nginx 转发,保证安全与简化网络暴露面。
一、为什么我不再在 VPS 上直接编译 Next.js 了?
一开始我和很多人一样:
- 廉价 VPS 上同时跑着 4~5个 nextjs 服务
- VPS 上
git pull npm installnpm run buildpm2 restart/next start
直到项目开始变复杂。
1)Next.js 编译非常吃内存
在 2G / 4G 内存的 VPS 上,next build 经常会:
- 编译到一半卡死
- 被 OOM Killer 干掉
- SSH 断开
- 甚至整机失联
为了让它“勉强能跑”,我不得不:
- 临时停掉 Redis、Nginx、其他服务
- 加 swap
- 小心翼翼一次只跑一个项目
非常不稳定,也非常消耗精力。
2)编译环境不可控,维护成本越来越高
另一个更隐蔽的问题是:
- Node 版本、npm、lockfile
- 系统依赖(openssl、glibc、字体、chromium…)
这些都与 VPS 强绑定。
同一份代码:本地能编译,服务器不一定能。
每次出问题,都要 SSH 上去“猜环境”。
二、为什么我决定改成 Docker + GitHub Actions?
这一节特别写给:使用廉价 VPS(1C2G / 2C2G / 2C4G),内存小、CPU 弱的独立开发者。
如果你发现自己经常为了
next build去关服务、加 swap、祈祷不要 OOM,其实不是你技术不行,而是 这个工作不该在 VPS 上做。
核心目标只有一句话:
服务器只负责跑容器,不再负责编译代码。
迁移后的流程:
- push 到
main - GitHub Actions:
- checkout 代码
npm ci+next build- build Docker 镜像
- push 到镜像仓库
- VPS:
docker compose pulldocker compose up -d- 服务自动重启
带来的直接好处:
- VPS 再也不会因为编译 OOM
- 编译环境可复现(Docker)
- 部署操作自动化(Actions)
- 出问题可回滚镜像
一句话总结:
把“不稳定的事”交给 CI,把“稳定的事”留给服务器。
三、整体架构说明:宿主机 Nginx + 容器化应用
我当前采用的结构是:
- 宿主机 Nginx(未容器化):
- 处理 HTTPS / 证书
- 反向代理到
127.0.0.1:3000(或其他本地端口)
- Docker Compose:
- 运行 Next.js 容器(可选:Redis、队列等)
- 端口仅绑定
127.0.0.1,避免服务直接暴露公网
这样做的好处:
- Nginx 仍用你熟悉的系统服务方式管理
- 应用部署与回滚完全镜像化
- 公网暴露面最小化
四、Next.js Docker 化的基本结构
0️⃣ 先开启 output: 'standalone'(强烈建议)
如果你希望 Docker 镜像更小、运行时不依赖完整 node_modules,强烈建议开启 standalone 模式。
在 next.config.js 或 next.config.mjs 中加入:
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'standalone',
}
module.exports = nextConfig
standalone 的好处
- Docker 镜像体积明显变小
- runner 阶段无需完整
node_modules - 更适合 CI 构建 + VPS 运行的模式
也正因为如此,后面的 Dockerfile 才可以只拷贝:
.next/standalone .next/static public
1)Dockerfile(多阶段构建)
核心原则:
- build 阶段做所有重活
- runtime 阶段只跑产物
# syntax=docker/dockerfile:1.6
############################
# 1) deps:安装依赖
############################
FROM node:22-slim AS deps
WORKDIR /app
# 证书(HTTPS 拉依赖/请求第三方)
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
# 先拷贝依赖清单以最大化缓存
COPY package.json package-lock.json* ./
RUN npm ci
############################
# 2) builder:编译 Next.js
############################
FROM node:22-slim AS builder
WORKDIR /app
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
&& rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# 关闭 Next 遥测
ENV NEXT_TELEMETRY_DISABLED=1
# -------------------------
# (可选) 构建期 Public Env(例如 Clerk 这类在 build 阶段会校验的配置)
# 需要在 GitHub Actions 中通过 build-args 传入
# -------------------------
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
# -------------------------
# (可选) 构建前生成命令:Prisma / OpenAPI / GraphQL Codegen 等
# 你的项目如果需要,取消注释即可
# -------------------------
# RUN npx prisma generate
# RUN npx openapi-typescript ./openapi.json -o ./src/types/openapi.d.ts
# RUN npx graphql-codegen
# 构建阶段编译
RUN npm run build
############################
# 3) runner:仅运行产物
############################
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
ENV NEXT_TELEMETRY_DISABLED=1
# 运行时仅保留必要工具
RUN apt-get update && apt-get install -y --no-install-recommends \
ca-certificates \
curl \
&& rm -rf /var/lib/apt/lists/*
# -------------------------
# (可选) Puppeteer:如果你的项目需要在容器里跑 Chromium
# 方案:安装系统 Chromium + 指定 executablePath,并禁用 Puppeteer 自己下载
# 你的项目如果需要,取消注释即可
# -------------------------
# RUN apt-get update && apt-get install -y --no-install-recommends \
# chromium \
# fonts-noto-cjk \
# fontconfig \
# && rm -rf /var/lib/apt/lists/*
# ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
# ENV PUPPETEER_SKIP_DOWNLOAD=true
# 只拷贝 standalone 运行所需文件
COPY --from=builder /app/public ./public
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/.next/standalone ./
EXPOSE 3000
CMD ["node", "server.js"]
关键点:使用
output: 'standalone'时,runner 阶段只需要拷贝standalone + static + public。
五、.github/workflows/deploy.yml(可直接用的示例)
在使用这份 deploy.yml 之前,有三个必须提前准备的点,否则 GitHub Actions 无法正常连接你的 VPS 或完成构建。下面把这三件事单独说明清楚,方便直接照做。
下面这份 deploy.yml 是我实践后稳定使用的版本:
- 推送到
main自动构建镜像并推送到 Docker Hub - SSH 到 VPS,生成
docker-compose.yml并拉取最新镜像重启 - 容器端口仅绑定
127.0.0.1
你需要在 GitHub 仓库的 Settings → Secrets and variables → Actions 中配置:
DOCKER_USERNAME/DOCKER_PASSWORDSERVER_HOSTSERVER_SSH_KEY
name: Deploy Next.js (Docker Compose)
on:
push:
branches: ["main"]
env:
IMAGE_NAME: adfuture/aimazing.site
SERVICE_NAME: aimazing-site
HOST_PORT: 3001
DEPLOY_PATH: /data/aimazing-site-docker
jobs:
build-and-deploy:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout Code
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_PASSWORD }}
- name: Build and Push Image
uses: docker/build-push-action@v6
with:
context: .
file: ./Dockerfile
push: true
tags: |
${{ env.IMAGE_NAME }}:latest
${{ env.IMAGE_NAME }}:${{ github.sha }}
cache-from: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache
cache-to: type=registry,ref=${{ env.IMAGE_NAME }}:buildcache,mode=max
# (可选) build 阶段需要的 public env(例如 Clerk publishableKey)
# 需要先在 GitHub Secrets 中配置:NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
# build-args: |
# NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
- name: Deploy to VPS
uses: appleboy/ssh-action@master
with:
host: ${{ secrets.SERVER_HOST }}
username: root
key: ${{ secrets.SERVER_SSH_KEY }}
script: |
set -e
DEPLOY_PATH="${{ env.DEPLOY_PATH }}"
IMAGE_NAME="${{ env.IMAGE_NAME }}"
SERVICE_NAME="${{ env.SERVICE_NAME }}"
HOST_PORT="${{ env.HOST_PORT }}"
mkdir -p "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
# 生成 docker-compose.yml(顶格输出,避免缩进导致 YAML 解析异常)
cat > docker-compose.yml <<EOF
services:
app:
image: ${IMAGE_NAME}:latest
container_name: ${SERVICE_NAME}
restart: unless-stopped
ports:
- "127.0.0.1:${HOST_PORT}:3000"
env_file:
- .env
environment:
# 运行在宿主机 Nginx 后面,仅用于容器内部探活
HOSTNAME: 127.0.0.1
PORT: 3000
# (可选) Redis:如果你把 redis 也放进 compose,用服务名访问
# REDIS_HOST: redis
# REDIS_PORT: "6379"
# (可选) Redis:如果启用 redis 服务,取消注释 depends_on
# depends_on:
# - redis
healthcheck:
# 直接使用固定的本地回环地址进行探活
test: ["CMD-SHELL", "curl -fsS https://127.0.0.1:3000/health >/dev/null || exit 1"]
interval: 15s
timeout: 5s
retries: 5
start_period: 20s
logging:
driver: "json-file"
options:
max-size: "10m"
max-file: "3"
# (可选) Redis:需要的话直接取消注释即可
# redis:
# image: redis:7-alpine
# container_name: ${SERVICE_NAME}-redis
# restart: unless-stopped
# command: ["redis-server", "--appendonly", "yes"]
# volumes:
# - redis_data:/data
# healthcheck:
# test: ["CMD", "redis-cli", "ping"]
# interval: 10s
# timeout: 3s
# retries: 10
# volumes:
# redis_data:
EOF
# 确保 .env 存在(你在 VPS 上手动维护真实变量)
test -f .env || touch .env
docker compose pull
docker compose up -d --remove-orphans
docker image prune -f
六、Docker 化过程中我遇到的典型问题
在继续之前,先补充一个非常容易被忽略、但新手一定会卡住的准备步骤:如何配置 GitHub Secrets(SERVER_HOST / SERVER_SSH_KEY)。
0️⃣ 如何配置 GitHub Secrets(SERVER_HOST / SERVER_SSH_KEY)
1)生成 SERVER_HOST
SERVER_HOST 就是你的 VPS 公网 IP 或域名,例如:
1.2.3.4
或:
vps.example.com
在 GitHub 仓库中依次进入:
Settings → Secrets and variables → Actions → New repository secret
- Name:
SERVER_HOST - Value:你的 VPS IP 或域名
2)生成 SERVER_SSH_KEY(完整可复制命令)
在你自己的电脑(而不是 VPS)上执行:
ssh-keygen -t ed25519 -C "github-actions" -f ~/.ssh/github_actions
会生成两个文件:
~/.ssh/github_actions # 私钥(给 GitHub) ~/.ssh/github_actions.pub # 公钥(给 VPS)
接下来,把公钥添加到 VPS:
cat ~/.ssh/github_actions.pub | ssh root@YOUR_VPS_IP "mkdir -p ~/.ssh && cat >> ~/.ssh/authorized_keys"
然后,把 私钥内容复制到 GitHub Secrets:
cat ~/.ssh/github_actions
- Name:
SERVER_SSH_KEY - Value:上面输出的完整私钥内容(包含
BEGIN/END)
建议:这个 key 只用于 CI 部署,不要和你平时登录 VPS 的 key 混用。
这一部分我尽量采用 「修改前 / 修改后」或「最小代码块」 的形式,方便你直接复制使用。
1️⃣ Compose 变量 ≠ env_file
问题现象:
container_name: ${SERVICE_NAME}
但 .env 里已经有 SERVICE_NAME,启动时却是空值。
原因:
container_name / image / ports这类字段- 只读取 Compose 解析阶段变量(shell 或同目录
.env) env_file:仅注入到容器运行时
建议做法:
- Compose 级变量在
deploy.yml的 SSH 脚本中显式定义 - 业务变量才放
.env
2️⃣ Redis 连接地址需要同步改代码
修改前(宿主机直连):
redis://127.0.0.1:6379
修改后(Docker Compose 内部网络):
redis://redis:6379
或通过环境变量统一:
REDIS_HOST=redis REDIS_PORT=6379
`redis://${process.env.REDIS_HOST}:${process.env.REDIS_PORT}`
核心认知:容器里的
127.0.0.1只指向当前容器自身,并不是宿主机,也不是其他容器。
127.0.0.1 不是宿主机,也不是其他容器。**
3️⃣ 构建前需要执行额外命令(以 Prisma 为例)
问题现象:
- 本地能跑
- Docker build 阶段
next build失败
原因往往是 缺少生成代码。
正确放置位置(Dockerfile / builder 阶段):
# 在 npm run build 之前 RUN npx prisma generate RUN npm run build
原则:所有“生成型命令”,都必须进入 Docker 构建流程。
4️⃣ Puppeteer / Chromium 在容器中找不到浏览器
修改前(默认写法):
puppeteer.launch()
修改后(显式指定路径):
puppeteer.launch({
executablePath: process.env.PUPPETEER_EXECUTABLE_PATH,
args: ["--no-sandbox", "--disable-setuid-sandbox"],
})
同时在 Dockerfile 中指定:
ENV PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium ENV PUPPETEER_SKIP_DOWNLOAD=true
5️⃣ Clerk 在 build 阶段报 Missing publishableKey
问题原因:
- Next.js build 阶段会执行部分组件代码
- CI / Docker build 环境中缺少 public env
GitHub Actions(传入 build-arg):
build-args: |
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=${{ secrets.NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY }}
Dockerfile(builder 阶段注入):
ARG NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY ENV NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=$NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY
必须放在:
RUN npm run build
之前。
6️⃣ Healthcheck 失败:监控地址不对
常见问题:
healthcheck: test: ["CMD-SHELL", "curl -fsS http://127.0.0.1:3000/ || exit 1"]
在某些框架或自定义 server 中,/ 并不是可访问的健康接口。
推荐做法:
- 在应用中暴露一个固定健康接口,例如:
/health
- 在
docker-compose.yml中通过环境变量统一控制:
environment: HOSTNAME: http://127.0.0.1:3000
healthcheck: test: ["CMD-SHELL", "curl -fsS $HOSTNAME/health || exit 1"]
这样做的好处:
- 健康检查地址可配置
- 多环境(端口 / 前缀)无需改 compose 结构
- 更贴近真实业务存活状态
七、额外:Docker Hub 私有仓库配额与 GHCR
- Docker Hub 免费账号通常只有 1 个私有仓库额度。
- 如果你有多个私有项目,建议迁移到 GHCR(GitHub Container Registry):
- 私有镜像更灵活
- 和 GitHub Actions 原生集成
(后续我会单独写一篇:从 Docker Hub 迁移到 GHCR 的完整改写模板。)
结语
从「VPS 上手动编译」到「Docker + CI 自动部署」,最大的变化不是技术,而是思路:
我不再把服务器当“开发环境”,而是当成一个稳定运行的基础设施节点。
一旦把构建迁出 VPS:
- 编译失败不再影响线上
- VPS 不再因为 OOM 随机宕机
- 部署从“手工操作”变成了“可复现流程”
如果你正在做 Next.js 项目,尤其是小内存 VPS,建议尽早把构建搬到 CI。
