visionA/docker/nginx.stage.conf
jim800121chen eb66a7287a feat(deploy): visionA Cloud dev / stage docker compose + Caddy/nginx + 部署腳本
新增雲端版部署設定(Phase 0.6 dev + Phase 0.7 stage 分兩套):

dev 環境(docker-compose.dev.yml):
- 5 service all-in-one(postgres + member-center + visionA-backend + frontend + Caddy)
- Caddy 自動 HTTPS for localhost
- .env.dev.example 範本(使用者拷出 .env.dev 後 docker compose up -d)
- Makefile dev-with-mc 9 個 target

stage 環境(docker-compose.stage.yml + docker/Dockerfile.stage):
- multi-stage build(node22 frontend + go1.26 backend × 2 + nginx-alpine runtime)
  最終 image 319 MB,含 nginx + nodejs + tini + bash
- entrypoint.stage.sh 4 process 共命運(nginx + api-server + remote-proxy +
  next.js standalone)用 wait -n + SIGTERM trap
- nginx.stage.conf:白名單 server_name stage-9527.innovedus.com + 444 default_server
  + /healthz 例外(127.0.0.0/8 only)+ /api/ 與 /storage/ 強制 no-store
  + /tunnel/connect WS upgrade + 100M body / 3600s timeout
- 對外 mapping 0.0.0.0:9527:80(公司 host nginx 在外層處理 HTTPS termination
  — Let's Encrypt stage-9527.innovedus.com 自動續簽)
- named volume visiona-data(不用 bind mount,因 stage docker daemon 在 host root
  無 mkdir 權限)

部署腳本(scripts/deploy-stage.sh):
- 仿 edge-ai-platform/scripts/deploy-docker.sh 早期 save/load 模式
- 為什麼不用 internal registry:公司 192.168.0.130:5000 開了 auth、無帳密
- 流程:buildx --load → docker save | gzip → DOCKER_HOST docker load → compose up
- 含 --rollback <tag> / --skip-build / --no-push / --skip-deploy 選項
- timestamp + git SHA tag 留 rollback 餘地

文件(docs/):
- DEV-SETUP.md:dev 環境一鍵起步驟
- SMOKE-TEST.md:手動煙測 checklist(OIDC flow / pairing / tunnel)
- STAGE-DEPLOY.md:stage 完整手冊(架構圖 / 環境前置 / 部署 step / rollback /
  7 種故障排除 / 緊急救回 POC)

.env.stage.example 對齊 backend A1 改造:
- VISIONA_OIDC_CLIENT_SECRET 留空(PKCE-only public client)
- VISIONA_OIDC_SERVICE_CLIENT_ID/_SECRET 留空(Phase 1 預留鉤子)
- 所有 secret 用 placeholder(CHANGE_ME_OPENSSL_RAND_HEX_32)

.dockerignore:避免 node_modules / .next / .git 等進 build context

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:22:44 +08:00

263 lines
11 KiB
Plaintext
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# visionA — stage 環境 nginx 設定
#
# 角色定位:
# 公司 host nginx ── HTTPS :9527LE 證書) ──→ container :80本檔
# ↓
# ┌───────────────────────────────────────────────────────────────┐
# │ container 內 nginx :80 │
# │ /healthz → 直接回 200給 docker healthcheck
# │ /api/* → 127.0.0.1:3721 (api-server) │
# │ /storage/* → 127.0.0.1:3721 (api-server presigned URL) │
# │ /tunnel/connect→ 127.0.0.1:3800 (remote-proxy WS upgrade) │
# │ /_next/static/ → 127.0.0.1:3000 (next standalone, 1y cache) │
# │ / → 127.0.0.1:3000 (next standalone server.js) │
# └───────────────────────────────────────────────────────────────┘
#
# 上游 process 由 entrypoint.stage.sh 啟動,全在同一 container loopback。
# ────────── upstream 定義 ──────────
# 在 server block 外層 alias讓 proxy_pass 可重用。
upstream visiona_api {
server 127.0.0.1:3721;
keepalive 32;
}
upstream visiona_tunnel {
server 127.0.0.1:3800;
keepalive 16;
}
upstream visiona_frontend {
server 127.0.0.1:3000;
keepalive 32;
}
# WebSocket upgrade map給 /tunnel/connect 用)
# 在 http context 用server block 內也可繼承。
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
# ────────── Host header 白名單M2 — trust boundary ──────────
#
# 公司 host nginx 已 termination HTTPSstage-9527.innovedus.com:9527
# 但 reverse proxy 把原始 Host header 透傳進來。為避免 backend 拿到偽造 Host
# 來組 absolute URLreturn_to / email link / cache key這層強制白名單
#
# - 任何不符合 server_name 的 host含直接打 IP、攻擊者偽造 Host→ 444 close
# - 命中 stage-9527.innovedus.com 的請求才進真正的 server block
#
# 444 = nginx 專屬 status直接關連線、不回 response不給攻擊者反饋。
server {
listen 80 default_server;
listen [::]:80 default_server;
server_name _;
# /healthz 例外Docker healthcheck 從 container 內打 localhost/healthz
# Host: localhost 不命中 stage-9527 白名單,但內部源頭可信任)
# 限制 source = 127.0.0.0/8 防止外部偽造 Host 跳過白名單
location = /healthz {
allow 127.0.0.0/8;
allow ::1/128;
deny all;
# 直接內部回 200不轉到 api-server避免 api-server 也要實作 /healthz
return 200 "ok\n";
add_header Content-Type "text/plain" always;
}
# 其他任何 host header 不符合白名單 → 444 close不回 response 給攻擊者反饋)
return 444;
}
server {
listen 80;
listen [::]:80;
server_name stage-9527.innovedus.com;
# ============================================================
# 全域行為
# ============================================================
# 模型上傳上限PRD §8.4 — Phase 0 為 100 MB
# /api/models/* 走 multipart/form-data這個值是上限。
client_max_body_size 100M;
# 大量 long-lived 連線tunnel WS、SSE、模型轉檔輪詢— 拉長 read timeout
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
# gzip — 對 JSON / JS / CSS 有效,避免重複壓縮二進位資源
gzip on;
gzip_min_length 1024;
gzip_types
text/plain
text/css
text/xml
text/javascript
application/json
application/javascript
application/xml
image/svg+xml;
# 安全 headersnginx 已有公司 host 那層 HTTPS這層補通用安全
# X-Frame-Options 預設 SAMEORIGIN 給 iframe 防護agent / pair view 不嵌 iframe
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# 注意:不在這層加 HSTSHTTPS termination 在公司 host nginx由那層加
# ============================================================
# 健康檢查 — 不打到 backenddocker healthcheck 用
# ============================================================
location = /healthz {
access_log off;
return 200 "ok\n";
add_header Content-Type text/plain;
}
# ============================================================
# /api/* → api-server :3721
# 包含 /api/auth/*OIDC callback、/api/devices、/api/models、/api/pairing 等
# ============================================================
location /api/ {
proxy_pass http://visiona_api;
proxy_http_version 1.1;
proxy_buffering off; # 模型上傳串流 / SSE friendly
# ── Proxy headers ──
# X-Forwarded-Proto = https讓 backend 產的 redirect / cookie Secure 判斷正確
# (公司 host nginx 已 terminationcontainer 收到的是 HTTP
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 https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
# ── Cache 防護M3 ──
# /api/auth/me 等含 PII/api/auth/callback 帶 code/state
# 一律禁止任何中間 proxy / browser cache含 BFCache
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "no-store, no-cache, must-revalidate, private" always;
add_header Pragma "no-cache" always;
# nginx add_header 在 location level 會完全覆蓋 server level不 merge
# 因此 server level 的安全 header 必須在這裡 re-add
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# ============================================================
# /storage/* → api-server :3721
# presigned URL 走這條HMAC 簽章 query string、不帶 cookie
# 雛形 LocalFS backend 的下載端點
# ============================================================
location /storage/ {
proxy_pass http://visiona_api;
proxy_http_version 1.1;
proxy_buffering off;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header Connection "";
# ── Cache 防護M3 — 保守作法) ──
# presigned URL 含短期 HMAC、理論上不可重複使用但內容是用戶上傳模型敏感
# 一律禁止中間 proxy 共用 cache避免 query string 漏進 cache key 時被旁人取得。
# 若未來改 S3 backend + presigned 直連,這條 location 會被拆掉,屆時改 backend 自行決定 cache 策略。
proxy_no_cache 1;
proxy_cache_bypass 1;
add_header Cache-Control "private, no-store" always;
# nginx location level 的 add_header 會完全覆蓋 server level — re-add 安全 header
add_header X-Frame-Options "SAMEORIGIN" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}
# ============================================================
# /tunnel/connect — WebSocket upgrade由 visionA Agent 連入
# 要 long-lived24h 心跳級別);拉到 86400s。
# 注意path 必須是 /tunnel/connect不是 /tunnel/)— remote-proxy 只開這個 endpoint
# ============================================================
location /tunnel/connect {
proxy_pass http://visiona_tunnel;
proxy_http_version 1.1;
# WebSocket upgrade headers
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
# tunnel 連線可長達數小時,且心跳由 yamux 處理nginx 不要中途斷
proxy_read_timeout 86400s;
proxy_send_timeout 86400s;
# 不 buffer避免延遲 WS 訊框
proxy_buffering off;
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 https;
}
# ============================================================
# Next.js hashed static assets — 永久 cache
# /_next/static/{hash}.js 等
# ============================================================
location /_next/static/ {
proxy_pass http://visiona_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
# Hash 帶在路徑裡,內容變了路徑就變 → 可以 immutable 1 年
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable" always;
}
# ============================================================
# 其他靜態資源public/ 下的圖片、字型等)— 1 day cache
# ============================================================
location ~* ^/(favicon\.ico|.*\.(?:png|jpg|jpeg|gif|svg|webp|ico|woff|woff2|ttf|eot))$ {
proxy_pass http://visiona_frontend;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header Connection "";
expires 1d;
add_header Cache-Control "public, max-age=86400" always;
}
# ============================================================
# 全部其他請求 → Next.js standalone server (:3000)
# 包含:
# - / (首頁)
# - /login, /register, /account, /clusters, /devices, /devices/[id],
# /devices/pair, /models, /models/[id], /workspace/[deviceId], /settings
# - /_next/data/* (RSC payload)
# - /_next/image (Next image optimizer雖然 standalone 預設啟用 sharp)
# 不在這裡做 SPA fallback — Next.js server 自己會處理 404 與動態 route
# ============================================================
location / {
proxy_pass http://visiona_frontend;
proxy_http_version 1.1;
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 https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Connection "";
# SSE / streaming 友善
proxy_buffering off;
proxy_cache off;
}
}