Files
novalon-website/docs/deployment.md
T
zhangxiang a0328a623f chore(qa): 验收台账与证据入库 + 构建/部署配置同步
- docs/acceptance/qa-tracker.md:跨周期缺陷单一真源台账(§7=第五轮)。
- 周期 1/2 + iPhone SE/axe 验收证据目录、ACCEPTANCE_REVIEW 快照入库。
- 同步 README/CONTEXT/CLAUDE/DESIGN/testing/deployment/lessons-learned 口径;
  next.config/Dockerfile/nginx/Jenkinsfile/docker-compose/sentry/prisma 对齐
  standalone 产物装配与部署形态。
2026-09-28 10:48:09 +08:00

28 KiB
Raw Blame History

一、Nginx 配置文件(3 个)

1. nginx-static-production.conf

用途: 生产环境完整 Nginx 主配置文件(含反向代理),是 docker-compose-nginx.yml 挂载的配置。

关键内容:

  • 全局配置: worker_processes auto,使用 epoll,连接数 1024,Docker DNS 解析器 127.0.0.11
  • 安全头分工: 文档级安全头(CSP / X-Frame-Options: DENY / X-Content-Type-Options / Referrer-Policy / Permissions-Policy / X-XSS-Protection / X-DNS-Prefetch-Control)只由 next.config.mjs 的 headers() 发出(next.config.mjs:21-37);本文件不再 add_header 同名头部,只保留边缘职责:TLS/301、HSTS(Next 不发送)、gzip、limit_req,以及 /uploads/ 磁盘直服的 nosniff + sandbox CSP(该 location 不经 Next 应答,故需自行声明)。原因:nginx add_header 只追加不替换,两层各设一次会让浏览器收到 X-Frame-Options: DENY, SAMEORIGIN 的重复互斥值(见本文件 nginx-static-production.conf:8-20 的分工注释)
  • 限流: limit_req_zone 全局限速 100r/s
  • Gzip 压缩: 级别 6,覆盖 text/css/json/javascript/xml 等类型
  • Upstream 定义: gitea_app (gitea:3000)、jenkins_app (jenkins:8080)
  • 虚拟主机:
    • novalon.cn / www.novalon.cn -- 主站边缘:页面与 /api/*、/admin/* 全部反代到 standalone 运行时(@nextjs named location),不再从磁盘直服 HTML(原 try_files $uri $uri.html $uri/ /404.html 的 export 时代快路径已移除,理由见 nginx-static-production.conf:22-25);/var/www/novalon 只作静态资源//uploads/ 的磁盘直服根目录,SSL/TLS 1.2+1.3,HSTS 2 年,_next/static/ 与字体/图片缓存 1 年(未命中回源 @nextjs)
    • git.f.novalon.cn -- 反向代理到 Gitea(端口 3000)
    • ci.f.novalon.cn -- 反向代理到 Jenkins(端口 8080),超时 60s
  • SSL: Let's Encrypt ACME challenge 支持,独立子域名证书路径

2. nginx-static.conf

用途: 主站边缘配置片段(HTTP→HTTPS + HSTS + gzip + limit_req + 反代 standalone 应用),是 http 上下文的 include 片段而非完整 nginx.conf(无 events{}/http{} 外壳,见 nginx-static.conf:3-8)。

关键内容:

  • 仅配置 novalon.cn / www.novalon.cn,无 Gitea/Jenkins 反向代理
  • 静态根目录:ACME/落地场景用 /usr/share/nginx/html(:35),主站静态资源在 /var/www/novalon(:86),页面一律 proxy_pass http://novalon_app(:97,115,131)
  • SSL 配置较简化(ssl_ciphers HIGH:!aNULL:!MD5)
  • 安全头分工与 production 版一致:文档级头部由 next.config.mjs 单点发出,本片段只带 HSTS / Cache-Control / CORS / Content-Disposition(nginx-static.conf:9-20)
  • 不再自设 Permissions-Policy:该头现在也在应用层(next.config.mjs:33)

3. nginx-internal.conf

用途: 内部/开发环境的纯静态资源片段,监听端口 3000,无 SSL。

关键内容:

  • 监听 localhost:3000,根目录 /var/www/novalon
  • 无 SSL、无 HSTS、无 ACME challenge
  • 只保留 Gzip 与静态资源缓存;文档级安全头不在本片段(唯一来源 next.config.mjs,见 nginx-internal.conf:7-11)
  • 唯一使用者 Dockerfile.static 已删除,本片段现处于「保留但无人引用」状态,删除前需同步本文件(nginx-internal.conf:3-6)

二、Docker 相关文件(5 个)

4. Dockerfile

用途: 单机应用镜像 -- Node.js 构建 + Node.js 运行时承载 Next.js 16 standalone(不是 nginx 静态站)。

关键内容:

  • 构建阶段: node:20-alpine,npm ci 安装依赖,npm run build 构建(Dockerfile:21-28)
  • 运行阶段: node:20-alpine,拷入 dist/standalone、dist/static、public(Dockerfile:53-57),非 root 用户 nextjs,EXPOSE 3000,CMD ["node", "server.js"](Dockerfile:61-67)
  • 镜像内不再有 nginx 层:文档级安全头由 next.config.mjs 的 headers() 发出,容器直连时头部依然完整(Dockerfile:65-66)

5. Dockerfile.prod

用途: 服务器侧镜像(docker-compose.server.yml 使用),不在镜像内构建:直接 COPY 本地已产出的 standalone 产物。

关键内容:

  • sharp-deps 阶段拉 @img/sharp-linuxmusl-* 覆盖 standalone 里的 darwin 二进制(Dockerfile.prod:4-10、:30)
  • runner 阶段 COPY dist/standalone、dist/static、public,ENV PORT=3000 HOSTNAME=0.0.0.0,EXPOSE 3000,CMD ["node", "server.js"](Dockerfile.prod:12-38)
  • 已删除:原 Dockerfile.static(纯静态 nginx 容器,COPY 一个 standalone 模式下根本不会产出的 html/ 目录)已从仓库移除,其配套片段 nginx-internal.conf 现无人引用

6. docker-compose.yml

用途: 单机应用容器,使用 Dockerfile 构建(跑 standalone 运行时,非静态 HTML)。

关键内容:

  • 服务名 novalon-website,镜像标签 novalon-website:1.0.0
  • 容器名 novalon-website
  • 端口映射 3000:3000(容器内 server.js 监听 0.0.0.0:3000;TLS/跳转/HSTS 属边缘,见 nginx-static.conf)
  • env_file: .env.production + DATABASE_URL=file:/app/data/prod.db,卷 ./data:/app/data、./uploads:/app/public/uploads;历史缺陷(曾用本文件把 dist/ 当静态根交给 nginx 并映射 80/443)已在文件头注明

7. docker-compose.server.yml

用途: 服务器端 Compose 配置,使用 Dockerfile.prod 构建(docker-compose.server.yml:7)。

关键内容:

  • 服务名 novalon-website,镜像标签 novalon-website:latest
  • 容器名 novalon-website
  • 端口映射 3000:3000
  • 使用外部网络 novalon-network(bridge 驱动)
  • 上传目录挂载为 ./uploads:/app/public/uploads(与 src/lib/media/storage.ts 的落盘根 <cwd>/public/uploads 对齐;docker-compose.yml:38 同)
  • 注:docker-compose.yml:36-37 的注释仍称本文件挂 /app/uploads,与实际挂载点不符,改该文件时一并修正

8. docker-compose-nginx.yml

用途: 独立 Nginx 反向代理容器配置(生产环境网关)。

关键内容:

  • 服务名 nginx,使用官方 nginx:alpine 镜像
  • 容器名 novalon-nginx-secure
  • 端口映射 80:80 和 443:443
  • 挂载:
    • nginx-static-production.conf -> /etc/nginx/nginx.conf(只读)
    • conf.d/ -> /etc/nginx/conf.d(只读,子域名虚拟主机片段)
    • ssl/ -> /etc/nginx/ssl(只读)
    • logs/ -> /var/log/nginx
    • ../certbot -> /var/www/certbot(ACME 验证)
    • ../novalon-static -> /var/www/novalon(只读,静态资源磁盘直服根)
    • ./sites -> /var/www/sites(只读,产品子站根)
  • 使用外部网络 novalon-network

三、部署脚本(1 个统一入口)

9. scripts/deploy.sh

用途: 统一发布脚本(唯一发布入口),负责构建、发布、回滚与状态查看。

关键内容:

  • 默认服务器 139.155.109.62(root 用户),远程目标目录 /home/novalon/docker-app/novalon-website/dist(生产 Nginx 实际挂载目录)
  • 命令:
    • build:构建静态产物
    • deploy:构建 + 远程发布(备份旧版本 -> rsync 上传 -> 设置权限 -> 重载 Nginx -> 验证页面)
    • rollback:回滚到最近一次远程备份
    • status:查看生产环境发布状态
  • 支持 --skip-build、--auto-rollback、--dry-run 及服务器/域名/备份保留数等参数
  • 支持环境变量覆盖:SERVER_IP、SERVER_USER、DEPLOY_ROOT、PROJECT_NAME、NGINX_CONTAINER、DOMAIN、BACKUP_RETENTION_COUNT、DIST_DIR

四、引用 "novalon-nginx" / "novalon-website" 的其他文件(29 个)

除上述 9 个核心文件外,以下文件也引用了这两个关键词,按类别归纳:

类别 文件路径
文档 README.md, DEPLOYMENT.md, docs/deployment.md, docs/deployment/DEPLOYMENT.md, docs/deployment/phase1-deployment-guide.md, docs/deployment/rollback-procedure.md, docs/development/getting-started.md, docs/STRUCTURE_PLAN.md, docs/PRODUCTION_RELEASE_REPORT.md, docs/PRODUCTION_DEPLOYMENT_LIGHTWEIGHT.md, docs/PRODUCTION_DEPLOYMENT.md, docs/PERFORMANCE_OPTIMIZATION.md, docs/OPTIMIZATION_REPORT.md, docs/MONITORING_SETUP.md, docs/MONITORING_QUICKSTART.md, docs/HMR-ERROR-SOLUTIONS.md, docs/CDN_CONFIGURATION.md
脚本 scripts/ssl-wildcard-dns.sh, scripts/ssl-individual-http.sh, scripts/ssl-individual-http-v2.sh, scripts/setup-wildcard-ssl.sh, scripts/monitoring/setup-monitoring.sh, scripts/deploy-wildcard-domain.sh, scripts/deploy-subdomain-ssl.sh

五、架构总结(2026-04-22 更新)

该项目的部署架构已优化为单层容器模式:

当前架构

┌─────────────────────────────────────────────────────────┐
│  novalon-nginx-secure (唯一容器) - 端口 80/443           │
│                                                         │
│  nginx-static-production.conf (主配置)                   │
│  └── include /etc/nginx/conf.d/*.conf;                  │
│                                                         │
│  conf.d/ (模块化配置目录)                                │
│  ├── git.f.novalon.cn.conf (Gitea 反向代理)             │
│  ├── ci.f.novalon.cn.conf (Jenkins 反向代理)            │
│  └── *.novalon.cn.conf (产品站点配置)                   │
│                                                         │
│  sites/ (产品静态文件目录)                               │
│  ├── product-a/                                         │
│  └── product-b/                                         │
│                                                         │
│  挂载卷:                                                 │
│  - ./conf.d → /etc/nginx/conf.d                        │
│  - ./sites → /var/www/sites                            │
│  - ../novalon-static → /var/www/novalon                │
│  - ./ssl → /etc/nginx/ssl                              │
└─────────────────────────────────────────────────────────┘

架构优化说明

已移除的组件:

  • novalon-website 容器(原端口 3000)-- 功能已合并到 novalon-nginx-secure 容器

优化收益:

  • 资源节省:减少一个容器实例
  • 架构简化:单一网关,职责明确
  • 配置模块化:每个服务独立配置文件,互不影响

新增自动化脚本

脚本 用途
scripts/add-product-site.sh 一键添加新产品站点(配置文件 + 静态文件)
scripts/ssl-product-site.sh 为产品站点申请 SSL 证书

添加新产品站点流程

# 1. 添加 DNS A 记录: product-a.novalon.cn → 服务器IP

# 2. 运行添加脚本
./scripts/add-product-site.sh product-a ./product-a-files

# 3. 申请 SSL 证书
./scripts/ssl-product-site.sh product-a.novalon.cn

# 4. 重载 Nginx
docker exec novalon-nginx-secure nginx -s reload

历史架构(已废弃)

该项目的部署架构为双层容器模式:

1. 内层: novalon-website 容器(端口 3000)-- 由 Dockerfile.static 构建,仅提供静态文件服务 2. 外层: novalon-nginx-secure 容器(端口 80/443)-- 由 docker-compose-nginx.yml 启动,使用 nginx-static-production.conf 作为完整网关,负责 SSL 终止、静态文件服务、以及到 Gitea (git.f.novalon.cn) 和 Jenkins (ci.f.novalon.cn) 的反向代理

两个容器通过外部 Docker 网络 novalon-network(bridge 驱动)互联。域名 novalon.cn / www.novalon.cn 指向主站静态资源,SSL 证书通过 Let's Encrypt 获取并配置了自动续期 cron 任务。

现在我对你的生产环境架构有了清晰的了解。让我再确认几个关键细节:

现在我对你的架构完全清楚了。在给出方案之前,我需要确认几个关键信息:

好的,需求明确了。让我整理完整的部署方案。

任务理解

  • 目标:在现有 novalon-nginx 网关上,为 6-15 个独立 HTML 产品官网配置子域名路由 + SSL
  • 约束:每个产品用独立子域名(如 product-a.novalon.cn),SSL 需要新申请
  • 当前架构:novalon-nginx-secure 容器(nginx:alpine)作为网关,静态文件通过 volume 挂载

方案设计

推荐方案:Nginx 模板化 + 目录约定 + Let's Encrypt 自动化

核心思路是利用你现有的 novalon-nginx-secure 网关容器,通过目录约定 + 配置模板来管理多个产品站点,避免为每个产品创建独立容器。

1. 目录结构约定

在服务器上按以下结构组织产品页面:

/home/novalon/docker-app/novalon-nginx/
├── nginx-static-production.conf    # 主配置(include 子配置)
├── conf.d/                         # 产品站点配置目录(新增)
│   ├── product-a.novalon.cn.conf
│   ├── product-b.novalon.cn.conf
│   └── ...
├── ssl/                            # SSL 证书目录
│   ├── novalon.cn/
│   ├── git.f.novalon.cn/
│   ├── ci.f.novalon.cn/
│   ├── product-a.novalon.cn/      # 每个产品独立子目录
│   └── product-b.novalon.cn/
├── sites/                          # 产品静态文件目录(新增)
│   ├── product-a/
│   │   └── index.html
│   ├── product-b/
│   │   └── index.html
│   └── ...
└── docker-compose-nginx.yml

2. Nginx 主配置修改

在 nginx-static-production.conf 的 http {} 块末尾添加一行 include:

# 在 http {} 块末尾,现有 server 块之后添加:
include /etc/nginx/conf.d/*.conf;

3. 产品站点配置模板

每个产品一个独立配置文件,例如 conf.d/product-a.novalon.cn.conf:

适用范围注(2026-09-23 核对):本模板给 nginx 磁盘直服的独立静态子站 / 内部工具反代用(现状即 conf.d/ci.f.novalon.cn.conf:31-34、conf.d/git.f.novalon.cn.conf:27-31,其中 Gitea/Jenkins 需要 SAMEORIGIN 以便同源内嵌)。主站不在此列:novalon.cn 的文档级安全头由 next.config.mjs 的 headers() 单点发出且取 X-Frame-Options: DENY(next.config.mjs:21-37),把本模板的 add_header X-Frame-Options 复制进主站 location 会与应用层同名头叠加成非法的 DENY, SAMEORIGIN(缺陷成因见 nginx-static-production.conf:16-18)。

# 产品A官网 - product-a.novalon.cn
server {
    listen 80;
    server_name product-a.novalon.cn;

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    server_name product-a.novalon.cn;

    ssl_certificate /etc/nginx/ssl/product-a.novalon.cn/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/product-a.novalon.cn/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    root /var/www/sites/product-a;
    index index.html;

    # 静态资源缓存
    location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
        try_files $uri =404;
    }

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        limit_req zone=general burst=20 nodelay;
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;

    access_log /var/log/nginx/product-a-access.log;
    error_log /var/log/nginx/product-a-error.log;
}

4. Docker Compose 修改

在 docker-compose-nginx.yml 中添加新的 volume 挂载:

services:
  nginx:
    image: nginx:alpine
    container_name: novalon-nginx-secure
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
    volumes:
      - ./nginx-static-production.conf:/etc/nginx/nginx.conf:ro
      - ./conf.d:/etc/nginx/conf.d:ro          # 新增:产品站点配置
      - ./ssl:/etc/nginx/ssl:ro
      - ./logs:/var/log/nginx
      - ../certbot:/var/www/certbot
      - ../novalon-static:/var/www/novalon:ro
      - ./sites:/var/www/sites:ro              # 新增:产品静态文件
    networks:
      - novalon-network

networks:
  novalon-network:
    driver: bridge
    external: true

5. SSL 证书申请脚本

为每个产品子域名申请 Let's Encrypt 证书:

#!/bin/bash
# scripts/ssl-product-site.sh
# 用法: ./ssl-product-site.sh product-a.novalon.cn

DOMAIN=$1
if [ -z "$DOMAIN" ]; then
    echo "用法: $0 <subdomain>.novalon.cn"
    exit 1
fi

CERTBOT_DIR="/home/novalon/docker-app/certbot"
SSL_DIR="/home/novalon/docker-app/novalon-nginx/ssl"

# 创建证书目录
mkdir -p "${SSL_DIR}/${DOMAIN}"

# 申请证书(使用 standalone 模式,需临时停止 nginx 的 80 端口)
docker run --rm \
    -p 80:80 \
    -v "${CERTBOT_DIR}:/var/www/certbot" \
    -v "/etc/letsencrypt:/etc/letsencrypt" \
    certbot/certbot certonly \
    --webroot \
    --webroot-path /var/www/certbot \
    -d "${DOMAIN}" \
    --email admin@novalon.cn \
    --agree-tos \
    --no-eff-email

# 复制证书到 nginx ssl 目录
cp "/etc/letsencrypt/live/${DOMAIN}/fullchain.pem" "${SSL_DIR}/${DOMAIN}/"
cp "/etc/letsencrypt/live/${DOMAIN}/privkey.pem" "${SSL_DIR}/${DOMAIN}/"

echo "✅ ${DOMAIN} 证书申请完成"

6. 一键添加新产品脚本

#!/bin/bash
# scripts/add-product-site.sh
# 用法: ./add-product-site.sh product-a /path/to/product-a-files

PRODUCT_NAME=$1
FILES_SOURCE=$2

if [ -z "$PRODUCT_NAME" ] || [ -z "$FILES_SOURCE" ]; then
    echo "用法: $0 <product-name> <html-files-directory>"
    echo "示例: $0 product-a ./product-a-website"
    exit 1
fi

DOMAIN="${PRODUCT_NAME}.novalon.cn"
NGINX_DIR="/home/novalon/docker-app/novalon-nginx"
CONF_DIR="${NGINX_DIR}/conf.d"
SITES_DIR="${NGINX_DIR}/sites"
SSL_DIR="${NGINX_DIR}/ssl"

# 1. 创建目录
mkdir -p "${SITES_DIR}/${PRODUCT_NAME}"
mkdir -p "${SSL_DIR}/${DOMAIN}"
mkdir -p "${CONF_DIR}"

# 2. 复制静态文件
cp -r "${FILES_SOURCE}/"* "${SITES_DIR}/${PRODUCT_NAME}/"

# 3. 生成 Nginx 配置
cat > "${CONF_DIR}/${DOMAIN}.conf" << 'CONF_TEMPLATE'
# 产品官网 - {{DOMAIN}}
server {
    listen 80;
    server_name {{DOMAIN}};

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        return 301 https://$host$request_uri;
    }
}

server {
    listen 443 ssl http2;
    server_name {{DOMAIN}};

    ssl_certificate /etc/nginx/ssl/{{DOMAIN}}/fullchain.pem;
    ssl_certificate_key /etc/nginx/ssl/{{DOMAIN}}/privkey.pem;
    ssl_protocols TLSv1.2 TLSv1.3;
    ssl_ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305;
    ssl_prefer_server_ciphers off;
    ssl_session_cache shared:SSL:10m;
    ssl_session_timeout 1d;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header X-XSS-Protection "1; mode=block" always;

    root /var/www/sites/{{PRODUCT_NAME}};
    index index.html;

    location ~* \.(css|js|jpg|jpeg|png|gif|webp|avif|svg|ico|woff|woff2|ttf|eot)$ {
        expires 1y;
        add_header Cache-Control "public, max-age=31536000, immutable";
        add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;
        try_files $uri =404;
    }

    location /.well-known/acme-challenge/ {
        root /var/www/certbot;
    }

    location / {
        limit_req zone=general burst=20 nodelay;
        try_files $uri $uri/ =404;
    }

    error_page 404 /404.html;

    access_log /var/log/nginx/{{PRODUCT_NAME}}-access.log;
    error_log /var/log/nginx/{{PRODUCT_NAME}}-error.log;
}
CONF_TEMPLATE

# 替换模板变量
sed -i "s/{{DOMAIN}}/${DOMAIN}/g" "${CONF_DIR}/${DOMAIN}.conf"
sed -i "s/{{PRODUCT_NAME}}/${PRODUCT_NAME}/g" "${CONF_DIR}/${DOMAIN}.conf"

echo "✅ 产品站点 ${PRODUCT_NAME} 配置完成"
echo ""
echo "后续步骤:"
echo "  1. 添加 DNS A 记录: ${DOMAIN} -> 服务器IP"
echo "  2. 申请 SSL 证书: ./ssl-product-site.sh ${DOMAIN}"
echo "  3. 重载 Nginx: docker exec novalon-nginx-secure nginx -s reload"

7. 完整部署流程

添加一个新产品站点的完整步骤:

步骤 1: DNS 解析
  → 在域名服务商添加 A 记录: product-a.novalon.cn → 139.155.109.62

步骤 2: 上传文件 + 生成配置
  → ./add-product-site.sh product-a ./product-a-html-files

步骤 3: 申请 SSL 证书
  → ./ssl-product-site.sh product-a.novalon.cn

步骤 4: 验证配置 + 重载 Nginx
  → docker exec novalon-nginx-secure nginx -t
  → docker exec novalon-nginx-secure nginx -s reload

步骤 5: 验证访问
  → curl -I https://product-a.novalon.cn

方案优势

特性 说明
零额外容器 所有产品站点复用现有 novalon-nginx-secure 网关
模板化 一键脚本生成配置,避免手动复制粘贴出错
隔离性 每个产品独立配置文件、独立日志、独立 SSL 证书目录
可扩展 新增产品只需 3 条命令
统一管理 所有配置集中在 conf.d/ 目录,便于审查和维护
资源高效 不需要为每个 HTML 页面启动独立容器

注意事项

  1. DNS 解析必须先完成:SSL 证书申请需要域名已指向服务器 IP
  2. 证书续期:建议在现有 cron 任务中加入产品子域名的自动续期
  3. 如果未来产品数量超过 15 个,建议升级为泛域名证书(*.novalon.cn),可大幅简化证书管理
  4. 文件更新:更新某个产品的 HTML 文件后,只需替换 sites/product-x/ 下的文件,无需重启 Nginx(静态文件通过 volume 挂载)

六、安全加固(2026-08-15 新增)

6.1 安全事件摘要(2026-08-14 发现并处置)

事件:生产服务器 Gitea 容器被入侵,git 操作被注入恶意钩子,导致 git pull 报 bad pack header / early EOF、响应延迟 4-6 秒。

攻击链还原:

  1. Gitea 内部 API(/api/internal/manager/add-logger)经 nginx location / 暴露到公网,配合泄露的 INTERNAL_TOKEN 可远程调用(攻击来源 IP:187.15.89.220,巴西)
  2. 利用 add-logger 的日志文件路径能力向 /data/gitea/home/.gitconfig 追加写入 [uploadpack] packObjectsHook = <base64 下载器>
  3. 每次 git 操作(upload-pack)触发钩子 → 从 C2 域名 sitego-step-hu.pages.dev/setup-mon(Cloudflare Pages,挖矿/后门分发)下载并执行 payload
  4. 恶意脚本以 git 用户权限回写 .gitconfig 与仓库 hooks/ 实现持久化 —— 持久化位于 /data 卷,容器重建后依然保留,这是本次排查最关键的教训

影响范围:Gitea 容器(git 用户权限);宿主机深度排查未发现入侵痕迹。

6.2 已执行的加固措施

措施 详情
Gitea 升级 1.27.0 → 1.27.2(compose 文件:novalon-cicd/gitea/docker-compose.yml)
清理恶意文件 重写 /data/gitea/home/.gitconfig(移除 packObjectsHook 与日志注入行);恢复 everything-is-suitable.git/hooks/pre-applypatch.sample 为标准样本
封堵内部 API nginx git.f.novalon.cn.conf 增加 location ~ ^/api/internal/ { return 403; }(80/443 两个 server 块均已加)
收紧代理信任 REVERSE_PROXY_TRUSTED_PROXIES = * → 172.20.0.0/16,127.0.0.1
凭据轮换 INTERNAL_TOKEN、SECRET_KEY、LFS_JWT_SECRET、OAuth JWT_SECRET、postgres 密码、4 个管理员密码全部更换
数据库清理 删除遗留 forgejo / woodpecker 数据库;引导超级用户角色改名 postgres(OID 不变,系统目录归属不受影响)
内存优化 Jenkins JAVA_OPTS 从 -Xmx2g -Xms512m 调为 -Xmx1024m -Xms256m;daemon.json 移除失效镜像源(dockerproxy.com 已死,会卡住镜像拉取)
SSE 长连接 location ~ /events$ 增加 proxy_buffering off + proxy_read_timeout 3600s(修复 /user/events 每 30s 被掐断的问题)

6.3 安全基线(必须长期保持)

  1. 内部 API 禁止公网可达:/api/internal/* 必须保持 403,任何新 nginx 配置不得代理该路径
  2. INTERNAL_TOKEN 不得泄露:禁止出现在日志、备份、代码仓库中;定期轮换
  3. REVERSE_PROXY_TRUSTED_PROXIES 保持最小化:仅 docker 网段 + 本机
  4. Gitea 版本保持最新:当前 1.27.2,关注官方安全公告(1.26.4 / 1.27.x 有安全修复)
  5. 管理员密码定期轮换;评估切换到 gitea/gitea:*-rootless 镜像(当前非 rootless,容器内 s6 以 root 运行)
  6. 凭据统一管理:postgres 密码同时存在于 app.ini 与 secrets/db_passwd,更换时必须两处同步

6.4 攻击排查速查表

项目 内容
恶意标记 giteahook / sitego-step / setup-mon / packObjectsHook / add-logger
重点检查点 /data/gitea/home/.gitconfig、各仓库 hooks/、docker diff <container>、ps aux | grep base64
根因验证 原始 git upload-pack 正常 + 经 Gitea HTTP 失败 ⇒ 怀疑钩子/转发层被篡改
取证目录 /root/incident-20260814/(恶意 .gitconfig、恶意 hook、容器 inspect、日志)
数据库备份 /root/db-cleanup-20260815/(forgejo.sql.gz / woodpecker.sql.gz)

6.5 已知限制与待办

  • 出网带宽 ≈ 1Mbps(腾讯云实例限制,实测 110KB/s):248MB 仓库全量 clone 约需 35 分钟;建议在腾讯云控制台升级带宽(如 5-10Mbps),这是当前 git 使用体验的唯一瓶颈
  • jenkins 容器:以 root 运行且挂载 /var/run/docker.sock 与 /root/.ssh(CI 构建需要,但一旦被攻破即等于宿主机沦陷,建议评估最小权限方案)
  • 遗留 forgejo 引用:dist_backup_20260422_161751/ 与 novalon-website/.woodpecker.yml 等旧文件中仍含 forgejo/woodpecker 配置,属历史归档,无需处理
  • 监控缺失:本次攻击 2026-08-11 开始,3 天后才被发现;建议配置 fail2ban + 容器日志告警 + 定时恶意文件扫描