Skip to content

Walnut Admin 全容器化部署实现计划 ​

📦 归档文档(2026-08-08,历史记录):这是一次迁移的过程记录,其中的 task 清单、临时命令、当时的目录结构都保持原样,未随代码演进而更新。 当前部署方式见仓库 deploy/README.md 与 CI/CD 与容器构建。

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: 将前端、后端、Nginx 全部容器化,用 GitHub Actions 构建镜像推到腾讯云 TCR,服务器上单一 docker-compose 部署,替换现有的 PM2 + 宿主机 Nginx 方案。

Architecture: 三个多阶段 Docker 镜像(frontend 静态服务 / backend NestJS / nginx 入口含 brotli)+ 服务器 deploy/ 目录单一 compose 编排(nginx → frontend/backend → mongodb×3 + redis,全内部网络)。CI 用 .env.keys(GitHub Secret)统一解密 env-encrypted 供前端构建。

Tech Stack: Docker / docker-compose / GitHub Actions / pnpm workspace / Vite / NestJS / nginx / brotli / 腾讯云 TCR(ccr.ccs.tencentyun.com)

前置条件(用户手动完成,不阻塞实现):

  1. GitHub Secrets 已配:ENV_KEYS(.env.keys 文件内容)、TCR_USERNAME(腾讯云账号 ID 100011854923)、TCR_PASSWORD(TCR 初始化密码);现有 PROD_HOST / PROD_USERNAME / PROD_PASSWORD 沿用
  2. 本地(Windows)有 Docker Desktop 可用,用于 Task 8 本地验证
  3. 本地 .env.keys 存在(1Password 获取),pnpm setup-env 可解密

Spec: apps/docs/src/zh-CN/content/archive/2026-08-08-dockerized-deployment-design.md


Task 0: 清理 env 目录体系(前置) ​

Files:

  • Delete: apps/admin/env/(模板目录,代码零引用)
  • Delete: apps/server/env/(模板目录,代码零引用)
  • Modify: apps/admin/env-encrypted/*.env.{development,stage,production}(补模板注释)
  • Modify: apps/server/env-encrypted/*.env.{development,stage,production}(补模板注释)
  • Modify: CLAUDE.md:143(env 目录描述)
  • Modify: apps/server/libs/config/README.md(修正"生产读 env/"的过时错误描述)
  • Modify: apps/docs/src/zh-CN/content/monorepo/env-management.md(三目录 → 两目录)

背景(已核实):

  • 前端 envDir = 'env-local'(build/constant.ts:1)、后端 envFilePath = cwd/env-local/.env.${env}(config.module.ts:19)、scripts/setup-env.ts 只操作 env-encrypted/ ↔ env-local/——env/ 模板目录代码零引用,纯文档性质

  • env-local/ 是解密产物落盘目录(vite/nest 唯一读取源),保留

  • dotenvx 加密文件保留注释行,可直接编辑补注释(不影响解密,解密时注释原样带出)

  • [ ] Step 1: 补全 env-encrypted 注释(模板注释搬入加密文件)

对比 apps/{admin,server}/env/ 模板与 env-encrypted/ 同名文件的注释差异,把模板中有价值的说明注释补进加密文件(示例:admin env/.env.production 中 VITE_PROXY 的"1 => use proxy"机制说明、server 模板中"do not change easily"类警告)。六个文件逐一处理(admin/server × development/stage/production),只改注释行,不动任何 encrypted:... 值。

验证: Run: pnpm setup-env Expected: 解密成功,且 apps/admin/env-local/.env.production 中能看到新补的注释行。

  • [ ] Step 2: 删除模板目录

Run:

bash
git rm -r apps/admin/env apps/server/env

Expected: 两个目录从索引删除(工作区同步删除)。

Run: git grep -rn "env/.env" -- apps scripts || true Expected: 无输出(代码无残留引用)。

  • [ ] Step 3: 修正文档

CLAUDE.md:143 原描述("env/ (templates) → env-encrypted/ → env-local/")改为:

- Environment: `env-encrypted/` (committed, AES-256 encrypted via dotenvx, 注释即模板) → `env-local/` (plaintext, generated by `pnpm setup-env`, gitignored). Private key in `.env.keys` (gitignored, shared via 1Password).

apps/server/libs/config/README.md 第 3 行与第 79 行附近,把"Loads environment-specific .env files from env/ (production) or env-local/ (development)"改为"Loads environment-specific .env files from env-local/(由 pnpm setup-env 解密生成)"。

apps/docs/src/zh-CN/content/monorepo/env-management.md:更新目录结构图与表格(env/ 行删除,env-encrypted/ 行注明"注释即模板"),并删除文末指向 apps/admin/env、apps/server/env 的模板链接。

  • [ ] Step 4: 验证删除后解密/开发不受影响

Run:

bash
pnpm setup-env
ls apps/admin/env-local apps/server/env-local

Expected: env-local 三个环境文件齐全(解密不依赖 env/ 模板)。

Run(可选): pnpm dev:admin 启动后无 env 校验错误(zod 校验通过)。

  • [ ] Step 5: Commit
bash
git add -A
git commit -m "refactor(env): remove template dirs, keep env-encrypted (comments as template) + env-local"

Task 1: 根 .dockerignore ​

Files:

  • Create: .dockerignore

  • [ ] Step 1: 创建文件

创建 .dockerignore(构建 context 为仓库根,三个 Dockerfile 共用):

gitignore
# 依赖与构建产物(不进 context,构建缓存友好)
node_modules
**/node_modules
**/dist
**/dist-*
**/.turbo
**/*.tsbuildinfo

# 版本控制与本地环境
.git
.gitignore
.env.keys
**/env-local
**/env-encrypted

# 文档与历史(构建不需要)
apps/docs
migration-guide
report

# 日志与缓存
**/*.log
**/logs
.cache

说明:**/env-local 会被排除——但前端构建需要 env-local!见 Task 3 的处理方式(构建前把解密后的 env 复制到 apps/admin/env-local/ 且用 COPY 独立步骤)。若实现时发现 vite build 读不到 env,改为不排除 apps/admin/env-local(仅该目录),中间层含密钥但最终镜像不含(多阶段只 COPY dist),可接受。

  • [ ] Step 2: 验证 dockerignore 生效

Run: docker build --no-cache -f apps/admin/Dockerfile --target builder -t check-dockerignore . 2>&1 | tail -5 (此命令在 Task 3 之后才可执行;此处仅确认文件语法无误,最终验证在 Task 3 完成后统一做)

  • [ ] Step 3: Commit
bash
git add .dockerignore
git commit -m "chore(docker): add root .dockerignore for monorepo build context"

Task 2: 后端 Dockerfile ​

Files:

  • Create: apps/server/Dockerfile

关键事实(已从源码确认):

  • 构建:pnpm --filter @walnut/server build → nest build -c infra/nest/prod.json → 产物 apps/server/dist/walnut/admin/com/app/main.js

  • 依赖:@walnut/contract(workspace:*,需要先 pnpm --filter @walnut/contract build 产出 CJS dist)

  • 运行时:node dist/walnut/admin/com/app/main.js,NODE_ENV=production

  • 配置读取:process.cwd()/env-local/.env.production(config.module.ts:18-19),所以 WORKDIR 固定为 /app,env 文件由 compose 挂载到 /app/env-local/.env.production

  • 生产依赖提取用 pnpm deploy(会把已构建的 @walnut/contract dist 一并复制)

  • [ ] Step 1: 创建 Dockerfile

dockerfile
# ---------- builder ----------
FROM node:24-bookworm-slim AS builder

WORKDIR /app

# pnpm 固定版本(root package.json 的 packageManager 字段)
RUN corepack enable && corepack prepare pnpm@11.20.0 --activate

# 全量源码(monorepo context)
COPY . .

# workspace 依赖安装(--frozen-lockfile 保证与 CI 一致)
RUN pnpm install --frozen-lockfile

# workspace 共享包 CJS 构建(@walnut/server 依赖其 dist)
RUN pnpm --filter @walnut/contract build

# 后端构建
RUN pnpm --filter @walnut/server build

# 提取生产依赖到 /out(含 node_modules + package.json + workspace 包产物)
RUN pnpm deploy --filter=@walnut/server --prod /out

# ---------- runtime ----------
FROM node:24-bookworm-slim AS runtime

ENV NODE_ENV=production
WORKDIR /app

COPY --from=builder /out ./

# 日志目录(winston daily rotate),compose 可挂载
RUN mkdir -p /app/logs

EXPOSE 3000

CMD ["node", "dist/walnut/admin/com/app/main.js"]
  • [ ] Step 2: 本地构建验证(需先解密 env,见 Task 8 前置;此处只验证镜像能构建、产物齐全)

Run:

bash
docker build -f apps/server/Dockerfile -t walnut-server-test .

Expected: 构建成功(约 3-6 分钟,取决于网络)。

Run: docker run --rm walnut-server-test ls /app Expected: 输出包含 dist、package.json、node_modules、logs。

Run: docker run --rm walnut-server-test ls /app/node_modules/@walnut Expected: 包含 contract(其 dist 已随 deploy 复制,含 index.js 等 CJS 产物)。

  • [ ] Step 3: Commit
bash
git add apps/server/Dockerfile
git commit -m "feat(docker): add multi-stage Dockerfile for @walnut/server"

Task 3: 前端 Dockerfile ​

Files:

  • Create: apps/admin/Dockerfile
  • Create: deploy/nginx/frontend-server.conf(前端容器内部 nginx 配置,静态文件服务)

关键事实(已从源码确认):

  • 构建:pnpm --filter @walnut/admin build → vite build,envDir = 'env-local'(build/constant.ts:1),即构建输入是 apps/admin/env-local/(gitignored,由 pnpm setup-env 解密生成)

  • 产物目录:VITE_BUILD_OUT_DIR(env-local 真实值,任务开始前先确认)

  • 前端依赖 @walnut/* 均为 workspace 包,Vite 直接消费源码(resolve.conditions: ['source']),无需预构建

  • 构建内存:本地 turbo 用 8G heap;CI(GitHub runner ~7GB)设 4G 防 OOM

  • [ ] Step 1: 确认 VITE_BUILD_OUT_DIR 真实值

Run:

bash
pnpm setup-env
grep VITE_BUILD_OUT_DIR apps/admin/env-local/.env.production

Expected: 一个相对目录(如 dist-prod 或 dist)。记下该值,下文 <OUT_DIR> 替换。

  • [ ] Step 2: 创建前端容器内部 nginx 配置

创建 deploy/nginx/frontend-server.conf(此文件在构建时被 COPY 进前端镜像):

nginx
server {
    listen 80;
    server_name _;

    root /usr/share/nginx/html;
    index index.html;

    # history 路由回退
    location / {
        try_files $uri $uri/ /index.html;
    }

    # 预压缩 .br 文件(构建时 vite-plugin-compression2 生成)
    brotli_static on;
    brotli on;
    brotli_comp_level 6;
    brotli_min_length 20;
    brotli_types text/plain text/css application/json application/x-javascript text/xml application/xml application/xml+rss text/javascript application/javascript image/svg+xml;

    gzip on;
    gzip_vary on;
    gzip_comp_level 6;
    gzip_min_length 10240;
    gzip_types text/plain text/css text/javascript application/javascript application/json application/xml image/svg+xml;

    # 静态资源强缓存(文件名带 hash)
    location /static/ {
        expires 1y;
        add_header Cache-Control "public, immutable";
    }
}

注意:brotli_static 需要 brotli 模块——前端镜像将使用 Task 4 的 brotli 镜像,而非官方 nginx:alpine。若构建报错 "unknown directive brotli_static",说明镜像未包含模块,回到 Task 4 检查模块安装。

  • [ ] Step 3: 创建 Dockerfile
dockerfile
# ---------- builder ----------
FROM node:24-bookworm-slim AS builder

WORKDIR /app

RUN corepack enable && corepack prepare pnpm@11.20.0 --activate

# 构建内存:CI runner 约 7GB,本地 8G 配置直接沿用会 OOM
ENV NODE_OPTIONS=--max-old-space-size=4096

COPY . .

# env-local 在 .dockerignore 中排除,单独显式 COPY(构建输入)
# 若本地验证时构建失败(vite 找不到 env),改为:COPY . .(并同步调整 .dockerignore 保留 env-local)
COPY apps/admin/env-local/.env.production apps/admin/env-local/.env.production

RUN pnpm install --frozen-lockfile

RUN pnpm --filter @walnut/admin build

# ---------- runtime ----------
# 使用 Task 4 构建的 brotli 镜像(含 brotli_static/filter 模块)
FROM walnut-admin/nginx-brotli:latest AS runtime

COPY --from=builder /app/apps/admin/<OUT_DIR> /usr/share/nginx/html
COPY deploy/nginx/frontend-server.conf /etc/nginx/conf.d/default.conf

EXPOSE 80

<OUT_DIR> 替换为 Step 1 查到的真实值。 注意 FROM walnut-admin/nginx-brotli:latest 是本地镜像名(Task 4 构建),最终 CI 中会改为 TCR 镜像地址——见 Task 6。

  • [ ] Step 4: 本地构建验证

Run:

bash
docker build -f apps/admin/Dockerfile -t walnut-frontend-test .

Expected: 构建成功。若失败于 vite env 校验(zod),按 Dockerfile 注释处理(调整 .dockerignore 保留 env-local)。

Run:

bash
docker run --rm -d -p 18080:80 --name frontend-test walnut-frontend-test
sleep 2
curl -s http://127.0.0.1:18080/ | head -20
docker rm -f frontend-test

Expected: curl 返回 index.html 内容(<title>... 等),非 403/404。

  • [ ] Step 5: Commit
bash
git add apps/admin/Dockerfile deploy/nginx/frontend-server.conf
git commit -m "feat(docker): add multi-stage Dockerfile for @walnut/admin with brotli static server"

Task 4: nginx brotli 入口镜像 ​

Files:

  • Create: deploy/nginx/Dockerfile

关键事实:

  • nginx:alpine 官方镜像是 apk add nginx 安装(与 Alpine 仓库同源),因此 apk add nginx-mod-http-brotli 版本天然匹配(同 repo 同版本线)

  • brotli 动态模块路径:/usr/lib/nginx/modules/ngx_http_brotli_filter_module.so 与 ngx_http_brotli_static_module.so

  • load_module 指令必须出现在 http 块之前;官方 nginx:alpine 的 nginx.conf 是否 include /etc/nginx/modules/*.conf 需构建期验证(Step 3 兜底)

  • [ ] Step 1: 创建 Dockerfile

dockerfile
FROM nginx:1.27-alpine

# Alpine 官方仓库提供 brotli 动态模块(与镜像内 nginx 同源同版本线)
RUN apk add --no-cache nginx-mod-http-brotli

# load_module 必须置于 http 块之前;官方 nginx.conf 若未 include modules 目录,
# 则覆盖 nginx.conf(见 Step 3 备选)
RUN printf 'load_module /usr/lib/nginx/modules/ngx_http_brotli_filter_module.so;\nload_module /usr/lib/nginx/modules/ngx_http_brotli_static_module.so;\n' > /etc/nginx/modules-brotli.conf \
    && sed -i '1a include /etc/nginx/modules-brotli.conf;' /etc/nginx/nginx.conf

# 构建期验证:模块加载失败(版本不匹配)→ 构建失败
RUN nginx -t

sed -i '1a ...' 在第一行(user nginx; 或注释)后插入 include——若 nginx -t 报错(如 "unexpected end of file" 或指令位置错),备选方案:把两行 load_module 直接写入 /etc/nginx/nginx.conf 顶部(sed -i '1i ...')再 nginx -t。

  • [ ] Step 2: 本地构建验证

Run:

bash
docker build -f deploy/nginx/Dockerfile -t walnut-admin/nginx-brotli .

Expected: 构建成功且最后一步 nginx -t 输出 syntax is ok / test is successful。

Run: docker run --rm walnut-admin/nginx-brotli nginx -v Expected: 输出 nginx 版本号(1.27.x)。

  • [ ] Step 3: 验证 brotli 模块加载

Run:

bash
docker run --rm walnut-admin/nginx-brotli nginx -t 2>&1

Expected: syntax is ok 且无 "unknown directive load_module" 错误。若报错,执行备选方案(把 load_module 写入 nginx.conf 顶部)后重跑 Step 2。

  • [ ] Step 4: Commit
bash
git add deploy/nginx/Dockerfile
git commit -m "feat(docker): nginx image with brotli modules (alpine official packages)"

Task 5: deploy/ 编排目录 ​

Files:

  • Create: deploy/docker-compose.yml
  • Create: deploy/.env.example
  • Create: deploy/nginx/conf.d/frontend.conf(入口 nginx:前端域名)
  • Create: deploy/nginx/conf.d/api.conf(入口 nginx:API 域名)
  • Create: deploy/env/README.md(env 文件放置说明)

关键事实(已确认):

  • 现有数据库/Redis compose(apps/server/db/docker-compose.prod.yml)的容器名、卷名(mongodb_master_data、redis_data)在新编排中原样保留 → 服务器上无缝接管,数据不丢

  • MongoDB 副本集成员通过 MONGODB_ADVERTISED_HOSTNAME 用容器名互访;后端连接串 host 从 127.0.0.1 改为服务名(在服务器 env 文件里改,见 Task 7)

  • 后端容器:/app/env-local/.env.production(挂载);HTTP 3000,Socket 5173(SOCKET_SERVER_PORT)

  • 入口 nginx 映射宿主 80/443;证书挂载 ./nginx/certs:/etc/nginx/certs:ro

  • 域名占位:<FRONTEND_DOMAIN>(www.walnut-admin.com)、<API_DOMAIN>(api.walnut-admin.com)——实现时以服务器现网 conf 为准(SSH 查看 /etc/nginx/conf.d/)

  • [ ] Step 1: 创建 compose

创建 deploy/docker-compose.yml:

yaml
services:
  # ---------- 入口 ----------
  nginx:
    image: ccr.ccs.tencentyun.com/walnut-admin/nginx:${IMG_TAG}
    restart: always
    container_name: walnut-nginx
    ports:
      - '80:80'
      - '443:443'
    volumes:
      - ./nginx/conf.d:/etc/nginx/conf.d:ro
      - ./nginx/certs:/etc/nginx/certs:ro
    depends_on:
      - frontend
      - backend

  # ---------- 应用 ----------
  frontend:
    image: ccr.ccs.tencentyun.com/walnut-admin/frontend:${IMG_TAG}
    restart: always
    container_name: walnut-frontend

  backend:
    image: ccr.ccs.tencentyun.com/walnut-admin/backend:${IMG_TAG}
    restart: always
    container_name: walnut-backend
    env_file: ./env/.env.production
    volumes:
      - ./logs:/app/logs
    depends_on:
      - prod-mongodb-primary
      - prod-mongodb-secondary
      - redis
    healthcheck:
      test: ['CMD', 'node', '-e', "fetch('http://127.0.0.1:3000/api/w/v1/auth/captcha/cap').then(r=>{process.exit(r.ok?0:1)}).catch(()=>process.exit(1))"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 30s

  # ---------- 数据层(沿用现有容器名与卷名,无缝接管)----------
  prod-mongodb-primary:
    image: docker.io/bitnami/mongodb:latest
    restart: always
    container_name: prod-mongodb-primary
    environment:
      - MONGODB_REPLICA_SET_MODE=primary
      - MONGODB_ROOT_USER=root
      - MONGODB_ROOT_PASSWORD=${MONGODB_ROOT_PASSWORD}
      - MONGODB_REPLICA_SET_KEY=${MONGODB_REPLICA_SET_KEY}
      - MONGODB_REPLICA_SET_NAME=${MONGODB_REPLICA_SET_NAME}
      - MONGODB_ADVERTISED_HOSTNAME=prod-mongodb-primary
    volumes:
      - 'mongodb_master_data:/bitnami/mongodb'

  prod-mongodb-secondary:
    image: docker.io/bitnami/mongodb:latest
    restart: always
    container_name: prod-mongodb-secondary
    depends_on:
      - prod-mongodb-primary
    environment:
      - MONGODB_REPLICA_SET_MODE=secondary
      - MONGODB_INITIAL_PRIMARY_HOST=prod-mongodb-primary
      - MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017
      - MONGODB_INITIAL_PRIMARY_ROOT_USER=root
      - MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=${MONGODB_ROOT_PASSWORD}
      - MONGODB_REPLICA_SET_KEY=${MONGODB_REPLICA_SET_KEY}
      - MONGODB_REPLICA_SET_NAME=${MONGODB_REPLICA_SET_NAME}
      - MONGODB_ADVERTISED_HOSTNAME=prod-mongodb-secondary

  prod-mongodb-arbiter:
    image: docker.io/bitnami/mongodb:latest
    restart: always
    container_name: prod-mongodb-arbiter
    depends_on:
      - prod-mongodb-primary
    environment:
      - MONGODB_REPLICA_SET_MODE=arbiter
      - MONGODB_INITIAL_PRIMARY_HOST=prod-mongodb-primary
      - MONGODB_INITIAL_PRIMARY_PORT_NUMBER=27017
      - MONGODB_INITIAL_PRIMARY_ROOT_USER=root
      - MONGODB_INITIAL_PRIMARY_ROOT_PASSWORD=${MONGODB_ROOT_PASSWORD}
      - MONGODB_REPLICA_SET_KEY=${MONGODB_REPLICA_SET_KEY}
      - MONGODB_REPLICA_SET_NAME=${MONGODB_REPLICA_SET_NAME}
      - MONGODB_ADVERTISED_HOSTNAME=prod-mongodb-arbiter

  redis:
    image: docker.io/bitnami/redis:latest
    restart: always
    container_name: prod-single-redis
    environment:
      - ALLOW_EMPTY_PASSWORD=no
      - REDIS_DISABLE_COMMANDS=FLUSHDB,FLUSHALL
      - REDIS_PASSWORD=${REDIS_PASSWORD}
    volumes:
      - redis_data:/bitnami/redis/data

volumes:
  mongodb_master_data:
    driver: local
  redis_data:
    driver: local

说明:

  • 端口收敛:MongoDB 三节点与 Redis 不再映射公网端口(原 compose 的 ports 全部移除)——spec 中的安全改进
  • ${MONGODB_*}、${REDIS_PASSWORD} 等来自 compose 同目录 .env(服务器上的真实值沿用现有 db compose 的硬编码值,Task 7 写入)
  • 后端 healthcheck 探测的路径 /api/w/v1/auth/captcha/cap 以实际 APP_API_PREFIX 为准(server env-local 中 APP_API_PREFIX 真实值确认后修正,本值不命中时 healthcheck 会失败——实现时用 curl 后端的真实可用端点)
  • 后端容器不再需要 env_file 外的其他配置;./logs 挂载为日志持久化
  • [ ] Step 2: 创建 .env.example

创建 deploy/.env.example:

env
# 镜像版本(CI 部署时写入;本地验证可用 latest)
IMG_TAG=latest

# 数据层密码(服务器上填现有 db compose 中的真实值)
MONGODB_ROOT_PASSWORD=
MONGODB_REPLICA_SET_KEY=
MONGODB_REPLICA_SET_NAME=WalnutAdminNestjsMongoDBReplicasetProdName
REDIS_PASSWORD=
  • [ ] Step 3: 创建入口 nginx 配置

创建 deploy/nginx/conf.d/frontend.conf(前端域名,域名/证书路径以实现时服务器现网 conf 为准):

nginx
server {
    listen 80;
    server_name <FRONTEND_DOMAIN>;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    server_name <FRONTEND_DOMAIN>;

    ssl_certificate     /etc/nginx/certs/<FRONTEND_DOMAIN>_bundle.crt;
    ssl_certificate_key /etc/nginx/certs/<FRONTEND_DOMAIN>.key;
    ssl_session_timeout 5m;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
    ssl_prefer_server_ciphers on;

    # 前端静态文件(frontend 容器)
    location / {
        proxy_pass http://frontend:80;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
    }

    # API 反代(backend 容器)
    location /api/ {
        proxy_pass http://backend:3000;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_http_version 1.1;
        add_header Access-Control-Allow-Credentials "true" always;
    }

    # Socket 反代(backend 容器 5173 端口;SOCKET_SERVER_PATH 以 env-local 真实值为准)
    location /socket/ {
        proxy_pass http://backend:5173/walnut-path/;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header Host $host;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
        proxy_connect_timeout 15s;
        proxy_cache_bypass $http_upgrade;
    }
}

创建 deploy/nginx/conf.d/api.conf(API 域名):

nginx
server {
    listen 443 ssl;
    server_name <API_DOMAIN>;

    ssl_certificate     /etc/nginx/certs/<API_DOMAIN>_bundle.crt;
    ssl_certificate_key /etc/nginx/certs/<API_DOMAIN>.key;
    ssl_session_timeout 5m;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-RSA-AES128-GCM-SHA256:HIGH:!aNULL:!MD5:!RC4:!DHE;
    ssl_prefer_server_ciphers on;

    location / {
        proxy_pass http://backend:3000;
        proxy_redirect off;
        proxy_http_version 1.1;
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection keep-alive;
        proxy_set_header Host $host;
        proxy_cache_bypass $http_upgrade;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Real-IP $remote_addr;
    }
}

域名与证书文件名以实现时从服务器拉取的现网 /etc/nginx/conf.d/*.conf 为准;<API_DOMAIN> 若有 80→443 跳转块一并保留。

  • [ ] Step 4: 创建 env 放置说明

创建 deploy/env/README.md:

markdown
# deploy/env/

服务器解密后的后端 env 文件放这里(**不入库,服务器上生成**):

1. 服务器上安装 pnpm + 克隆仓库或复制 `apps/server/env-encrypted/` 与根 `.env.keys`
2. 在仓库根运行 `pnpm setup-env` 解密
3. 将 `apps/server/env-local/.env.production` 复制为 `deploy/env/.env.production`

**注意:把连接串 host 从 `127.0.0.1` 改为服务名:**
- `APP_REDIS_HOST=prod-single-redis`
- `DATABASE_PRIMARY=prod-mongodb-primary:27017`
- `DATABASE_SECONDARY=prod-mongodb-secondary:27017`
- `DATABASE_ARBITER=prod-mongodb-arbiter:27017`

禁止改动:AUTH_OPAQUE_SECRET / MFA_ENCRYPTION_KEY / RT_ENCRYPTION_KEY / DEVICE_ID_ENCRYPTION_KEY / USER_ID_ENCRYPTION_KEY / USER_ID_HASH_SALT(改后历史数据无法解密)。
  • [ ] Step 5: 废弃旧数据库编排

删除 apps/server/db/docker-compose.prod.yml(其职责已并入根级编排,容器名/卷名完全一致):

Run:

bash
git rm apps/server/db/docker-compose.prod.yml

apps/server/docker/docker-compose.dev.yml(本地开发用)保留不动。

  • [ ] Step 6: 校验 compose 语法

Run: docker compose -f deploy/docker-compose.yml config -q Expected: 无输出(语法正确)。若提示未定义变量(如 IMG_TAG),先 cp deploy/.env.example deploy/.env。

  • [ ] Step 7: Commit
bash
git add deploy/
git rm -r apps/server/db 2>/dev/null || true
git commit -m "feat(deploy): add docker-compose orchestration with nginx entry (brotli, internal network)"

Task 6: 重写 CI 部署流水线 ​

Files:

  • Modify: .github/workflows/deploy.yml(整体重写)

关键事实:

  • 仓库公开 → Actions 免费无限量

  • 构建 context 为仓库根;三个镜像:nginx / frontend / backend,tag = git SHA

  • 镜像仓库:ccr.ccs.tencentyun.com/walnut-admin/*(TCR 个人版,账号 ID 作 username)

  • ENV_KEYS secret = .env.keys 文件内容(多行);写入根目录后 pnpm setup-env 解密出 apps/admin/env-local/(前端构建输入)

  • 构建内存:admin 构建设 4G(Dockerfile 已含 ENV)

  • 服务器部署:沿用现有 PROD_HOST / PROD_USERNAME / PROD_PASSWORD;docker compose pull && up -d

  • [ ] Step 1: 重写 workflow

创建(覆盖).github/workflows/deploy.yml:

yaml
name: Deploy Docker Images to Server

on:
  workflow_dispatch:
    inputs:
      environment:
        description: Environment to deploy
        required: true
        type: choice
        options:
          - prod

env:
  REGISTRY: ccr.ccs.tencentyun.com
  IMAGE_NS: walnut-admin
  TAG: ${{ github.sha }}

jobs:
  build-and-deploy:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Setup pnpm
        uses: pnpm/action-setup@v4
        with:
          version: 11

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: 24
          cache: pnpm

      - name: Install dependencies
        run: pnpm install --frozen-lockfile

      # 前端构建输入:.env.keys 解密 env-encrypted → env-local
      - name: Prepare env (.env.keys → setup-env)
        run: |
          echo "${{ secrets.ENV_KEYS }}" > .env.keys
          pnpm setup-env
        # 注意:secrets 会被 Actions 自动打码,日志安全

      - name: Login to Tencent TCR
        run: echo "${{ secrets.TCR_PASSWORD }}" | docker login $REGISTRY -u "${{ secrets.TCR_USERNAME }}" --password-stdin

      - name: Build & push backend
        run: |
          docker build -f apps/server/Dockerfile -t $REGISTRY/$IMAGE_NS/backend:$TAG .
          docker push $REGISTRY/$IMAGE_NS/backend:$TAG

      - name: Build & push frontend
        run: |
          docker build -f apps/admin/Dockerfile -t $REGISTRY/$IMAGE_NS/frontend:$TAG .
          docker push $REGISTRY/$IMAGE_NS/frontend:$TAG

      - name: Build & push nginx
        run: |
          docker build -f deploy/nginx/Dockerfile -t $REGISTRY/$IMAGE_NS/nginx:$TAG .
          docker push $REGISTRY/$IMAGE_NS/nginx:$TAG

      - name: Deploy on server
        uses: appleboy/ssh-action@v0.1.10
        with:
          host: ${{ secrets.PROD_HOST }}
          username: ${{ secrets.PROD_USERNAME }}
          password: ${{ secrets.PROD_PASSWORD }}
          script: |
            cd /opt/walnut-admin/deploy
            # 只替换 IMG_TAG 行,保留服务器上的人工配置(数据层密码等)
            if [ -f .env ] && grep -q '^IMG_TAG=' .env; then
              sed -i "s/^IMG_TAG=.*/IMG_TAG=${{ env.TAG }}/" .env
            else
              echo "IMG_TAG=${{ env.TAG }}" >> .env
            fi
            docker compose pull
            docker compose up -d
            docker image prune -f --filter "until=72h"

说明:

  • 部署目录固定为服务器 /opt/walnut-admin/deploy(Task 7 初始化;如需沿用现网目录,实现时调整)
  • 服务器 deploy/.env 首行为 IMG_TAG=,其余行(MONGODB_*、REDIS_PASSWORD)由人工维护,CI 只更新 IMG_TAG 行
  • 若需要 push main 自动触发,追加:
    yaml
    push:
      branches: [main]
    默认保持手动触发(与现网 release 流程一致)
  • [ ] Step 2: 验证 YAML 语法

Run: node -e "const y=require('js-yaml');console.log('ok')" 2>/dev/null || echo "skip"(js-yaml 可能未安装,跳过即可) 替代:docker run --rm -v $PWD/.github/workflows/deploy.yml:/w.yml mikefarah/yq e '.' /w.yml > /dev/null && echo "yaml ok"(有 Docker 时) Expected: yaml ok 或人工检查缩进。

  • [ ] Step 3: Commit
bash
git add .github/workflows/deploy.yml
git commit -m "ci(deploy): rewrite as docker pipeline (build → TCR → compose up)"

Task 7: 服务器部署手册 ​

Files:

  • Create: deploy/README.md

  • [ ] Step 1: 编写手册

创建 deploy/README.md(服务器端完整操作手册):

markdown
# Walnut Admin 服务器部署手册(全容器化)

## 首次初始化(一次性)

1. **安装 Docker + Compose 插件**(Ubuntu 24.04):

```bash
curl -fsSL https://get.docker.com | sh
systemctl enable --now docker
docker compose version   # 确认 Compose v2
```

2. **创建部署目录并放置文件**:

```bash
mkdir -p /opt/walnut-admin/deploy
# 从本仓库拷贝 deploy/ 目录(nginx/、docker-compose.yml、.env.example)
# 目录结构:
#   /opt/walnut-admin/deploy/
#   ├── docker-compose.yml
#   ├── .env                  ← 真实值(IMG_TAG + 数据层密码)
#   ├── env/.env.production   ← 后端 env(见 deploy/env/README.md)
#   ├── nginx/
#   │   ├── conf.d/*.conf     ← 域名/证书路径已按现网填写
#   │   └── certs/            ← SSL 证书(前端域名 + API 域名)
#   └── logs/
```

3. **准备后端 env**:见 `deploy/env/README.md`(连接串 host 改为服务名,密钥禁止改动)。

4. **放置证书**:把现网 `/etc/nginx/conf.d` 中的证书文件复制到 `deploy/nginx/certs/`(文件名与 conf 中路径一致)。

5. **填写 deploy/.env**:`IMG_TAG=latest`(首次)+ `MONGODB_ROOT_PASSWORD` / `MONGODB_REPLICA_SET_KEY` / `MONGODB_REPLICA_SET_NAME` / `REDIS_PASSWORD`(沿用现有 `apps/server/db/docker-compose.prod.yml` 中的真实值)。

6. **启动(首次会先拉数据库镜像,需国内镜像源或稍等)**:

```bash
cd /opt/walnut-admin/deploy
docker compose up -d
docker compose ps   # 全部 healthy/running
```

7. **下线旧体系**(确认新栈健康后):

```bash
pm2 delete walnut-admin-nestjs-prod        # 停旧后端
systemctl stop nginx && systemctl disable nginx   # 停宿主机 nginx
```

## 日常更新(CI 自动执行)

```bash
cd /opt/walnut-admin/deploy && docker compose pull && docker compose up -d
```

## 回滚

```bash
cd /opt/walnut-admin/deploy
sed -i "s/^IMG_TAG=.*/IMG_TAG=<上一版本SHA>/" .env
docker compose up -d
```

## 证书更新(一年一次)

```bash
# 替换 deploy/nginx/certs/ 下对应文件后:
docker exec walnut-nginx nginx -s reload
```

## 数据层说明

- 卷名 `mongodb_master_data` / `redis_data` 与旧 compose 一致 → 数据无缝继承
- MongoDB 三节点与 Redis 不再暴露公网端口(内部网络互访)
- 备份:`docker exec prod-mongodb-primary mongodump ...`(按需)
  • [ ] Step 2: Commit
bash
git add deploy/README.md
git commit -m "docs(deploy): add server operations manual (init/update/rollback/certs)"

Task 8: 本地端到端验证 ​

Files: 无(验证流程)

前置:本机 Docker Desktop 运行中;pnpm setup-env 已可解密(Task 3 Step 1 已跑过)。

  • [ ] Step 1: 构建三个镜像

Run:

bash
docker build -f deploy/nginx/Dockerfile -t walnut-admin/nginx-brotli .
docker build -f apps/server/Dockerfile -t walnut-server-test .
docker build -f apps/admin/Dockerfile -t walnut-frontend-test .

Expected: 全部成功。

  • [ ] Step 2: 本地起全栈(数据层用本地 compose 变体)

准备临时验证编排 /tmp/compose-local.yml(本地端口避让 + 无证书的入口)——仅验证容器间联通:

yaml
services:
  frontend:
    image: walnut-frontend-test
    ports: ['18080:80']
  backend:
    image: walnut-server-test
    env_file: ./env/.env.production
    ports: ['13000:3000']
    depends_on:
      - prod-mongodb-primary
      - redis
  # mongodb×3 + redis 同 deploy/docker-compose.yml(保留端口映射以便本地访问)

Run:

bash
cd deploy && docker compose -f /tmp/compose-local.yml up -d
docker compose -f /tmp/compose-local.yml ps

Expected: 所有容器 running。

  • [ ] Step 3: 验证后端

Run: curl -s http://127.0.0.1:13000/api/w/v1/auth/captcha/cap | head -c 200 Expected: JSON 响应(captcha 数据),非连接拒绝。

Run: docker logs walnut-backend 2>&1 | tail -20 Expected: Nest 启动日志,无 MongoDB/Redis 连接错误。

  • [ ] Step 4: 验证前端

Run: curl -s http://127.0.0.1:18080/ | grep -o '<title>[^<]*</title>' Expected: 输出页面 title(Vite 构建的 index.html)。

  • [ ] Step 5: 清理

Run: docker compose -f /tmp/compose-local.yml down -v Expected: 容器与临时卷删除。


上线切换清单(用户手动执行,顺序严格) ​

  1. 完成 Task 8 本地验证
  2. PR 合并所有 Task 提交到 main
  3. 服务器按 Task 7 手册初始化 deploy/ 目录(含 env/证书/密码)
  4. 先 docker compose up -d 起数据层 + 新栈(端口冲突:旧 nginx 占 80/443、旧 pm2 占 3000——先停旧体系再起新栈,或临时改端口验证)
  5. 验证域名 HTTPS / API / socket / 登录流程
  6. 停用旧体系(pm2、宿主机 nginx)
  7. GitHub Secrets 配置齐后手动触发 workflow 验证 CI 全链路
  8. 回滚演练:改 IMG_TAG 回上一版本

风险与缓解(实现时注意) ​

风险缓解
nginx brotli 模块版本不匹配Dockerfile 构建期 nginx -t fail fast;备选方案见 Task 4
CI 构建 admin OOM(runner 7GB)Dockerfile 已设 NODE_OPTIONS=--max-old-space-size=4096;仍失败则降到 3072
vite 读不到 env-local(dockerignore 排除)Task 3 已用独立 COPY 步骤;失败则调整 dockerignore
pnpm deploy 行为不符预期(contract dist 未复制)验证 /app/node_modules/@walnut/contract 存在 dist;备选:runtime 里 pnpm install --prod
Sentry sourcemap 上传失败导致 build 失败env-local 中 VITE_BUILD_SENTRY 临时设 false 或修正 token
CI 覆盖服务器 .env 丢失数据库密码workflow 中 .env 只追加/替换 IMG_TAG 行(实现时选择最稳做法)
healthcheck 探测端点不真实以实际可用 API 端点替换(先 curl 验证)
端口冲突(旧体系与新栈并存期)切换窗口先停旧体系;或新栈临时映射高位端口验证

基于 MIT 许可发布