当前位置: 首页 » 独立开发者 » Next.js Docker 化实战:小内存 VPS 迁移到 GitHub Actions 的完整方案

Next.js Docker 化实战:小内存 VPS 迁移到 GitHub Actions 的完整方案

output_99290.webp

一次 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 install
  • npm run build
  • pm2 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 上做

核心目标只有一句话:

服务器只负责跑容器,不再负责编译代码。

迁移后的流程:

  1. push 到 main
  2. GitHub Actions:
    • checkout 代码
    • npm ci + next build
    • build Docker 镜像
    • push 到镜像仓库
  3. VPS:
    • docker compose pull
    • docker 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.jsnext.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_PASSWORD
  • SERVER_HOST
  • SERVER_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 中,/ 并不是可访问的健康接口。

推荐做法:

  1. 在应用中暴露一个固定健康接口,例如:
/health
  1. 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。

滚动至顶部