团队写代码最费时间的不是写,是“等别人本地起环境看效果”。
staging 环境只有一个,分支一多就排队;本地 docker-compose up 又每个人机器不一样。
免备案VPS 在这里的价值不是“建站快”,而是:不用等备案、不用申请内网权限,开一台境外小机就能给每个 PR 发一个临时预览 URL。
本文给一套 Docker Compose + Traefik + 通配 DNS 的极简方案:PR 打开自动起栈、推代码热更新、合并/关闭自动销毁,顺手把合规边界(不存 PII、不公开收录、不面向境内公众服务)讲清。
一、先定边界:预览环境“能放什么”
免备案只解决“不用等 ICP 备案”,不解决内容合规和数据合规。
预览沙盒原则:
- 只跑分支代码,不接生产库(用独立预览库/假数据)
- 不存真实用户 PII(手机号/订单/支付用 mock)
- 预览域名加 Basic Auth 或 SSO,禁止搜索引擎收录
- 面向内部评审(开发/产品/测试),不面向公众推广
- 合并即删,磁盘不长期留分支残渣
它是一张“临时工单”,不是“线上站点”。
二、整体架构
text
开发者推 PR │ Webhook / CI ▼ CI 构建镜像 → 推私有 Registry │ SSH 到免备案VPS ▼ docker compose -p pr-184 up -d ├─ api (独立容器) ├─ worker (可选) ├─ postgres (独立库+独立 volume) └─ Traefik 标签路由:pr-184.preview.example.com │ ▼ 评审员访问 https://pr-184.preview.example.com (Basic Auth)
固定常驻组件:
- Traefik(唯一监听 80/443,自动签发通配证书)
- 清理定时器(cron / systemd timer)
不固定组件:
- 每个 PR 一个 Compose 项目,独立网络、独立卷
三、基础设施:一台 2C4G 香港/新加坡 VPS 起步
系统:Ubuntu 24.04 / Debian 12
bash
# 创建部署用户 adduser deploy usermod -aG docker,sudo deploy # 装 Docker apt-get install -y ca-certificates curl gnupg install -m 0755 -d /etc/apt/keyrings curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg echo "deb [arch=amd64 signed-by=/etc/apt/keyrings/docker.gpg] https://download.docker.com/linux/ubuntu $(. /etc/os-release && echo $VERSION_CODENAME) stable" > /etc/apt/sources.list.d/docker.list apt-get update apt-get install -y docker-ce docker-compose-plugin
DNS 只配两条:
text
A preview.example.com → VPS_IP CNAME *.preview.example.com → preview.example.com
四、Traefik 常驻边缘(自动路由+证书)
/opt/edge/docker-compose.yml:
yaml
services: traefik: image: traefik:v3.3 command: - --providers.docker=true - --providers.docker.exposedbydefault=false - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --certificatesresolvers.letsencrypt.acme.dnschallenge=true - --certificatesresolvers.letsencrypt.acme.dnschallenge.provider=cloudflare - --certificatesresolvers.letsencrypt.acme.email=ops@example.com - --certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json environment: CF_DNS_API_TOKEN: ${CF_TOKEN} ports: - "80:80" - "443:443" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - ./letsencrypt:/letsencrypt networks: - traefik-public networks: traefik-public: name: traefik-public
五、每个 PR 的 Compose 栈
CI 里生成临时 compose 文件(用 PR 号做 project 名):
yaml
# pr-184/docker-compose.yml services: api: image: registry.example.com/app/api:pr-184 labels: - traefik.enable=true - traefik.http.routers.api-pr184.rule=Host(`pr-184.preview.example.com`) - traefik.http.routers.api-pr184.entrypoints=websecure - traefik.http.routers.api-pr184.tls.certresolver=letsencrypt - traefik.http.middlewares.pr184-auth.basicauth.users=${BASIC_AUTH} environment: DATABASE_URL: postgres://preview:preview@db:5432/pr184 NODE_ENV: preview networks: - traefik-public - pr184 db: image: postgres:16-alpine environment: POSTGRES_USER: preview POSTGRES_PASSWORD: preview POSTGRES_DB: pr184 volumes: - pr184_pg:/var/lib/postgresql/data networks: - pr184 networks: traefik-public: external: true pr184: name: pr184 volumes: pr184_pg:
部署脚本(CI 里跑):
bash
ssh deploy@vps " mkdir -p /opt/previews/pr-184 cat > /opt/previews/pr-184/docker-compose.yml <<'EOF' $(cat pr-184/docker-compose.yml) EOF cd /opt/previews/pr-184 docker compose -p pr-184 up -d "
PR 评论自动回链:
bash
echo "预览环境已就绪:https://pr-184.preview.example.com" | gh pr comment 184 --body-file -
六、热更新:别每次重建库
- 代码层:CI 重新 build 镜像 →
docker compose -p pr-184 up -d只换 api 容器 - 数据库层:volume 保留,迁移脚本跑幂等
prisma db push/alembic upgrade - 依赖层:镜像构建走 Registry 缓存,别在 VPS 上现场
npm install
yaml
# 中间件加安全头 - traefik.http.middlewares.pr184-sec.headers.customresponseheaders.X-Robots-Tag=noindex,nofollow
Nginx 里再补一层:
nginx
add_header X-Robots-Tag "noindex, nofollow" always;
七、自动销毁:合并即消失
cleanup.sh:
bash
#!/bin/bash for d in /opt/previews/*/; do pr=$(basename "$d") # 查 PR 状态(GitHub CLI) state=$(gh pr view "${pr#pr-}" --json state -q .state 2>/dev/null) if [ "$state" = "MERGED" ] || [ "$state" = "CLOSED" ]; then docker compose -p "$pr" down -v rm -rf "$d" echo "$(date) destroyed $pr" fi done
systemd timer 每小时跑:
ini
[Timer] OnCalendar=hourly Persistent=true
再加 TTL 兜底(防 CI 挂了没清理):
bash
# 创建时写元数据 echo '{"pr":184,"expire":"2026-11-02T00:00:00Z"}' > /opt/previews/pr-184/meta.json # 清理脚本顺手看 expire
八、安全:预览环境最怕“被扫到”
- 通配域名不挂官网,不提交 sitemap
- Traefik 全局中间件:Basic Auth / Authelia 二选一
- 预览库用独立低权账号,禁止访问生产连接串
- Secret 不进预览容器:用 mock 配置,真实密钥只生产环境注入
- VPS 防火墙:只开 80/443,SSH 限 IP+密钥
- 日志脱敏:不打印用户 token、不存请求 body 全文
bash
ufw default deny incoming ufw allow 80,443/tcp ufw allow from 办公IP to any port 22222 ufw enable
九、资源隔离:别让一个 PR 拖垮全机
每个 project 加资源配额:
yaml
services: api: deploy: resources: limits: cpus: "1.0" memory: 512M
清理脚本顺手报:
- 内存压力
- 磁盘 inode
- 僵尸 volume(
docker volume ls里没容器引用的删掉)
bash
docker system prune -f --filter "until=24h"
十、合规边界(必须写进 README)
- 免备案 ≠ 免内容责任:预览站也不放违规内容
- 不接真实用户数据:用脱敏样本 / faker 生成
- 涉及境内用户信息的代码逻辑:预览环境只跑假数据,不真出网
- 隐私政策:预览域返回
noindex,不收集真实访客 PII - 数据跨境:预览库不长期留存,合并即清
十一、最小落地清单
- [ ] VPS 装 Docker + Traefik 常驻
- [ ] 通配 DNS + 自动证书
- [ ] CI 按 PR 号生成 compose,独立网络/卷
- [ ] 预览域 Basic Auth + noindex
- [ ] 合并/关闭 Webhook 触发销毁
- [ ] 每小时清理 + TTL 兜底
- [ ] 资源配额 + 日志脱敏
- [ ] 不接生产库、不存真实 PII
十二、什么别用预览沙盒干
- 跑生产流量灰度(用正规灰度发布)
- 放真实支付回调(用 mock 网关)
- 长期留分支环境(超 72h 自动删)
- 把密钥写进镜像层
- 让搜索引擎抓预览域
十三、总结
免备案VPS 在研发流里最被低估的用法,不是“先开个站”,而是:
让每个 PR 自己长出一个临时世界,合并完再消失。
代码评审从“看 diff”变成“点链接看效果”;
产品不用等 staging;
测试不用本地配环境;
资源不常驻,成本按 PR 生命周期计费。
备案不用等、环境不抢用、合并即归还。
一台小规格境外 VPS,
跑的不是服务,是“可销毁的确定感”。