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)
前置条件(用户手动完成,不阻塞实现):
- GitHub Secrets 已配:
ENV_KEYS(.env.keys 文件内容)、TCR_USERNAME(腾讯云账号 ID100011854923)、TCR_PASSWORD(TCR 初始化密码);现有PROD_HOST/PROD_USERNAME/PROD_PASSWORD沿用 - 本地(Windows)有 Docker Desktop 可用,用于 Task 8 本地验证
- 本地
.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:
git rm -r apps/admin/env apps/server/envExpected: 两个目录从索引删除(工作区同步删除)。
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:
pnpm setup-env
ls apps/admin/env-local apps/server/env-localExpected: env-local 三个环境文件齐全(解密不依赖 env/ 模板)。
Run(可选): pnpm dev:admin 启动后无 env 校验错误(zod 校验通过)。
- [ ] Step 5: Commit
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 共用):
# 依赖与构建产物(不进 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
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
# ---------- 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:
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
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:
pnpm setup-env
grep VITE_BUILD_OUT_DIR apps/admin/env-local/.env.productionExpected: 一个相对目录(如 dist-prod 或 dist)。记下该值,下文 <OUT_DIR> 替换。
- [ ] Step 2: 创建前端容器内部 nginx 配置
创建 deploy/nginx/frontend-server.conf(此文件在构建时被 COPY 进前端镜像):
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
# ---------- 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:
docker build -f apps/admin/Dockerfile -t walnut-frontend-test .Expected: 构建成功。若失败于 vite env 校验(zod),按 Dockerfile 注释处理(调整 .dockerignore 保留 env-local)。
Run:
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-testExpected: curl 返回 index.html 内容(<title>... 等),非 403/404。
- [ ] Step 5: Commit
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.soload_module指令必须出现在 http 块之前;官方 nginx:alpine 的 nginx.conf 是否 include/etc/nginx/modules/*.conf需构建期验证(Step 3 兜底)[ ] Step 1: 创建 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:
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:
docker run --rm walnut-admin/nginx-brotli nginx -t 2>&1Expected: syntax is ok 且无 "unknown directive load_module" 错误。若报错,执行备选方案(把 load_module 写入 nginx.conf 顶部)后重跑 Step 2。
- [ ] Step 4: Commit
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:
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:
# 镜像版本(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 为准):
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 域名):
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:
# 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:
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
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_KEYSsecret =.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:
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默认保持手动触发(与现网 release 流程一致)push: branches: [main]
- [ ] 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
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(服务器端完整操作手册):
# 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
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:
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(本地端口避让 + 无证书的入口)——仅验证容器间联通:
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:
cd deploy && docker compose -f /tmp/compose-local.yml up -d
docker compose -f /tmp/compose-local.yml psExpected: 所有容器 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: 容器与临时卷删除。
上线切换清单(用户手动执行,顺序严格)
- 完成 Task 8 本地验证
- PR 合并所有 Task 提交到 main
- 服务器按 Task 7 手册初始化
deploy/目录(含 env/证书/密码) - 先
docker compose up -d起数据层 + 新栈(端口冲突:旧 nginx 占 80/443、旧 pm2 占 3000——先停旧体系再起新栈,或临时改端口验证) - 验证域名 HTTPS / API / socket / 登录流程
- 停用旧体系(pm2、宿主机 nginx)
- GitHub Secrets 配置齐后手动触发 workflow 验证 CI 全链路
- 回滚演练:改 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 验证) |
| 端口冲突(旧体系与新栈并存期) | 切换窗口先停旧体系;或新栈临时映射高位端口验证 |