---
title: FR-063.3 落地版部署 — 環境變數與掛載完整清單
---

# 落地版（on-premise 容器版）部署設定清單

> 產出於 2026-08-15（CM-1192 / FR-063.3）。適用對象：把 `guidant-ai-be:<version>`
> image 裝到客戶主機的人。
>
> 前提：**正式 image 不帶任何設定檔**。編譯進 binary 的只有產品常數，所有部署差異
> 一律由外部注入（`--env-file` 或 compose `environment:`）。缺關鍵項不會拖到執行期
> 才出錯，啟動期就會**一次列出全部缺項**後中止。

---

## 0. 三分鐘上手（compose 定式，CM-1238 起）

正式部署一律走 repo 內的 **`docker/production/docker-compose.yml`**——所有掛載／
環境／重啟策略都已釘成定式（含 `read_only` 唯讀化與 tamper 標記 volume），
照抄即可，不必再手打長指令。

```bash
# ① 準備設定檔（照 §2 必填項填）
cp env.sample /srv/guidant-ai/guidant.env && vi /srv/guidant-ai/guidant.env

# ② 準備掛載目錄（owner 不必先喬，容器啟動時會自動 chown，見 §4）
mkdir -p /srv/guidant-ai/{static,log,home,pki}

# ③ 起兩個服務（api + socketio，同一個 image）
GUIDANT_VERSION=<version> docker compose -f docker/production/docker-compose.yml up -d
# 只動單一服務時必指定 service 名：... up -d guidant-api

# ④ 驗
curl -s localhost:8000/api/1.0/version    # {"data":{"commit":"...","version":"..."}}
curl -s localhost:8002/healthz            # {"mode":"socketio","status":"ok"}
```

可調變數（都有預設，詳見 compose 檔頭註解）：`GUIDANT_VERSION`（image tag）、
`GUIDANT_ENV_FILE`（預設 `/srv/guidant-ai/guidant.env`）、`GUIDANT_DATA_DIR`
（預設 `/srv/guidant-ai`）、`AGENT_CERT_DIR`（agent 憑證主機端目錄，須與 env 內
`AGENT_*` 路徑一致，見 §4）。

**換版**：改 `GUIDANT_VERSION`（或 pull 新 image）後重跑同一句 `up -d`，
compose 會自動 recreate，資料都在 volume 不受影響。

<details>
<summary><b>Fallback：無 compose 環境的 <code>docker run</code> 寫法</b>（點開）</summary>

```bash
# ③a 起 API 服務
docker run -d --name guidant-api --restart unless-stopped \
  --read-only --tmpfs /tmp:rw,size=2g --tmpfs /run:rw \
  --env-file /srv/guidant-ai/guidant.env \
  -p 8000:8000 \
  -v /srv/guidant-ai/static:/app/static \
  -v /srv/guidant-ai/log:/app/log \
  -v /srv/guidant-ai/home:/home/guidant \
  -v /srv/guidant-ai/pki:/opt/guidant/pki \
  -v /etc/machine-id:/etc/machine-id:ro \
  -v <憑證目錄>:<憑證目錄>:ro \
  guidant-ai-be:<version>
# ⚠️ <憑證目錄> 必須與 .env 內 AGENT_* 指的路徑一致（原因與驗證方式見 §4）
# 🔴 `/opt/guidant/pki` 這行不可省：那是 FR-064 tamper 標記落點，漏掛會落成
#    匿名 volume，`docker rm` 容器就把鎖定標記洗掉、防竄改鎖定失效（見 §4）

# ③b 起通知服務（**同一個 image、同一個 command**，只多一個環境變數）
docker run -d --name guidant-socketio --restart unless-stopped \
  --read-only --tmpfs /tmp:rw,size=2g --tmpfs /run:rw \
  --env-file /srv/guidant-ai/guidant.env \
  -e RUN_MODE=socketio \
  -p 8002:8002 \
  -v /srv/guidant-ai/static:/app/static \
  -v /srv/guidant-ai/log:/app/log \
  -v /srv/guidant-ai/home:/home/guidant \
  -v /srv/guidant-ai/pki:/opt/guidant/pki \
  -v /etc/machine-id:/etc/machine-id:ro \
  -v <憑證目錄>:<憑證目錄>:ro \
  guidant-ai-be:<version>
```

</details>

---

## 1. 🔴 env-file 的兩個寫法陷阱（實際撞過，先看這段）

`docker --env-file` 的格式與 shell 的 `.env` 慣例**不相容**：

| 寫法 | 結果 |
|------|------|
| `export FOO=bar` | ❌ docker 把 `export FOO` 整串當變數名，報 `variable 'export FOO' contains whitespaces` 並**拒絕啟動整個容器** |
| `FOO="bar"` | ⚠️ docker **不剝引號**，值變成 `"bar"`（含引號），密碼會因此對不上 |
| `FOO=bar` | ✅ 正確 |

`export ` 前綴是給 `source` 用的寫法，人不會覺得有錯——188 上的 `.env` 就有 7 行是這樣
（2026-08-15 實測撞到，容器直接起不來）。**交付前務必檢查**：

```bash
grep -nE '^[[:space:]]*export ' <你的 env 檔>   # 應無輸出
grep -nE '=".*"$'              <你的 env 檔>   # 應無輸出（或確認該值真的要含引號）
```

---

## 2. 核心必填（缺任一項啟動即中止）

啟動期由 `config/config_loader.py:validate_required_env()` 檢查，缺項會**一次列全**
（不是缺一個報一個），訊息形如 `缺少：DB_HOST, JWT_SECRET_KEY（或 JWT_SECRET）`。

| 變數 | 啟動期檢查 | 說明 |
|------|-----------|------|
| `DB_HOST` | ✅ 強制 | PostgreSQL 主機 |
| `DB_USER` | ✅ 強制 | DB 帳號 |
| `DB_PASSWORD` | ✅ 強制 | DB 密碼 |
| `JWT_SECRET_KEY` | ✅ 強制 | JWT 簽章金鑰。**每個客戶獨立生成**，不可跨部署點共用 |
| `REDIS_HOST` | ✅ 強制 | Redis 主機 |
| `REDIS_USER` | ⚠️ 未強制 | Redis 帳號。**啟動期不檢查**，但 Redis 有啟用帳密時漏填會在連線當下才失敗（症狀比缺項難查，故仍視為必填） |
| `REDIS_PASSWORD` | ⚠️ 未強制 | Redis 密碼，同上 |

**舊 JSON 包裝格式仍完全相容且優先**（既有部署零改動）：填了 `DB_SECRET` /
`REDIS_SECRET` / `JWT_SECRET` 就以它們為準。新裝機建議用上表的平鋪格式。

| 舊格式 | 新平鋪格式 |
|--------|-----------|
| `DB_SECRET={"rds_master_username":"u","rds_master_password":"p"}` | `DB_USER=u` + `DB_PASSWORD=p` |
| `REDIS_SECRET={"redis_user_name":"u","redis_user_password":"p"}` | `REDIS_USER=u` + `REDIS_PASSWORD=p` |
| `JWT_SECRET={"jwt_secret":"s"}` | `JWT_SECRET_KEY=s` |

---

## 3. 一般設定（有預設值，依部署點調整）

| 變數 | 預設 | 落地版建議 |
|------|------|-----------|
| `RUN_MODE` | `api` | api 容器不設；通知容器設 `socketio` |
| `PORT` / `SOCKET_PORT` | `8000` / `8002` | 容器各有獨立網路命名空間，通常不用改，只改對外 map 的 port |
| `DB_PORT` | `5432` | 依客戶 DB |
| `DB_NAME` | `compliance_manager` | 依客戶命名 |
| `ENV` | `DEV` | 純顯示標籤（只在啟動 log 印一行，**不驅動任何行為**）。落地版填 `PRD` |
| `DEBUG` | `false` | **落地版必須 false**。true 會開 SQL 全量 log（洩漏查詢內容且拖慢） |
| `CORS_ALLOWED_ORIGINS` | `*` | **落地版必填實際站台網址**（逗號分隔）。留 `*` 等於任何站台都能打這組 API |
| `LOG_DIR` | `./log` | 維持預設即可（容器內 cwd 固定 `/app`，故落在 `/app/log` 這個掛載點） |
| `USE_GUNICORN` | 未設＝`not DEBUG` | 落地版走 gunicorn（不設即可） |
| `GUNICORN_WORKERS` | `4` | 依客戶機器核心數調整 |
| `GUNICORN_TIMEOUT` | `120` | 匯出／大檔解析比一般請求久，機器慢時調大 |
| `SYSTEM_NAME` / `SYSTEM_URL` / `FRONTEND_BASE_URL` | — | 通知信內容與連結會用到 |
| `TZ` | **image 內已設 `Asia/Taipei`** | 🔴 **必須與該部署的主機／DB／其他服務一致**。跨時區客戶用 `-e TZ=<該地時區>` 覆寫，但不可各自為政——程式內有 naive datetime 比較（如 agent 在線判定），時區不一致會讓「東西是活的、判定卻壞掉」（見 §4 末的時區說明） |
| `ENABLE_MULTI_TENANT` | `true` | 單一客戶落地版通常仍維持 true |
| `MFA_REQUIRED` | `false` | DB `RUNTIME_CONFIG` 有值時以 DB 為準，本項是預設來源 |
| `LICENSE_ENFORCEMENT_ENABLED` | `true` | **顯式寫出**（曾因靠隱式預設而誤判） |
| `LICENSE_READONLY_GATE_ENABLED` | `true` | 同上 |
| `DEPLOYMENT_MODE` | `saas` | 落地版填 `host`（啟用機器指紋核對） |
| `TURNSTILE_SECRET_KEY` | — | 登入 captcha。**落地版（內網無外網）需評估是否停用**，否則登入會因連不到 Cloudflare 而失敗 |
| `DB_READ_HOST` | — | **目前未生效**（讀寫分離從未實際運作，無消費者），保留定義供未來使用 |

---

## 4. 掛載點（七個，缺一不可）

| 容器內路徑 | 用途 | 不掛的後果 |
|-----------|------|-----------|
| `/app/static` | 使用者上傳檔落地處 | **重啟即全丟**。問卷 Excel 上傳實測落點：`/app/static/file/answer/upload/<user>/` |
| `/app/log` | 應用 log | 重啟即丟，出事無從查起 |
| `/home/guidant` | `$HOME`：內含 `.cm-jobs`（背景 job 狀態）與 `.config/libreoffice`（LibreOffice profile，首次轉檔自建） | job 狀態遺失；LibreOffice 每次重建 profile |
| `/tmp` | LibreOffice 轉檔中繼（`TMPDIR`） | 用容器層，大檔轉檔可能吃爆容器可寫層。建議掛實體 volume，容量數 GB 以上 |
| `/opt/guidant/pki` | 🔴 **FR-064 tamper 標記落點**（`common/integrity/tamper_marker.py` 預設 `/opt/guidant/pki/.integrity-tamper`） | Dockerfile 有 VOLUME 宣告，不顯式掛載會落成**匿名 volume**——`docker rm` 容器時鎖定標記跟著消失，**防竄改鎖定形同虛設**（重建容器即解鎖）。2026-08-16 實際踩到，CM-1238 補洞 |
| `/etc/machine-id`（**唯讀，掛「檔」不是目錄**） | 🔴 **機器指紋錨點**（`common/license/machine_fingerprint.py` 第一級來源）——unlock token 綁機（FR-064.5）與 license 綁機（FR-062 D6）都以它為準 | image 內的 `/etc/machine-id` 是 **0 bytes 空檔**（Debian base image 標準行為，容器不跑 systemd 永遠填不上）→ 指紋退回 `uuid.getnode()`（容器 MAC）→ **每次 `docker restart` 就換一個指紋**：原廠簽的解鎖憑證重啟即失效（永遠解不了鎖）、license 綁機漂移。2026-08-16 實測撞到，CM-1242 補洞 |
| 憑證目錄（**路徑見下方 🔴**） | 檢測 agent 的 mTLS/JWT 憑證檔 | 不用檢測 agent 功能可不掛；要用時憑證**只能外部掛入**（不打進 image） |

#### 🔴 `/etc/machine-id` 掛載的前置條件：宿主機必須有這個檔

`-v /etc/machine-id:/etc/machine-id:ro` 掛的是**檔案**。宿主機若沒有這個檔，
docker 會**自動把它建成目錄**——容器內讀檔失敗、指紋悄悄退回 MAC，症狀與沒掛
完全一樣（服務全綠、只有解鎖與綁機壞掉）。裝機前先驗：

```bash
test -s /etc/machine-id && echo OK || echo "缺 machine-id，見下方補法"
# 缺的話（極精簡系統才會缺，systemd 系開機安裝即有）：
sudo systemd-machine-id-setup    # 或 sudo sh -c 'dbus-uuidgen > /etc/machine-id'
```

⚠️ **machine-id 一旦被重新生成，該機所有既有綁機憑證（unlock token／host 版
license）都會失效**，需向原廠重新申請。故只在「本來就沒有」時補，不要為了整齊
重新生成。

### 🔴 憑證掛載：路徑必須與 `AGENT_*` 的值**逐字一致**

這是實際踩過的坑（2026-08-15，188 上容器化後 agent 全數連不上）。

`AGENT_CA_CERT` 等變數存的是**檔案的絕對路徑字串**，程式直接拿去開檔
（`common/util/agent_auth/settings.py`）。它**不會**去別的地方找、也沒有預設位置——
所以容器內必須在**那個路徑**上真的有檔案，掛在別處等於沒掛。

失敗症狀：BE 起得正常、healthcheck 綠、API 都通，**只有 agent 連不上**
（讀不到 CA 憑證就無法完成 mTLS 驗證）。又是一個「服務看起來活著」的靜默失效。

**兩種做法，擇一：**

**做法 A（推薦，`.env` 零改動）** —— 把主機憑證目錄按**原路徑**掛進容器：

```bash
# .env 內若是 AGENT_CA_CERT=/opt/guidant-ai-be/scripts/pki/agent_dev/ca.crt
# 就照這個路徑掛，容器內外路徑相同：
-v /opt/guidant-ai-be/scripts/pki:/opt/guidant-ai-be/scripts/pki:ro
```

掛 `:ro` 唯讀 —— BE 只需要讀憑證，唯讀可防止容器意外寫壞 CA 私鑰。

**做法 B（路徑收斂，需同步改 `.env`）** —— 統一掛到 `/opt/guidant/pki`，
並把 `.env` 內六個 `AGENT_*` 路徑一起改成該目錄下的位置。新客戶裝機建議走這條，
既有部署轉換時**兩邊要一起改，改一半就是連不上**。

#### 🔴 憑證檔的 owner 必須是容器內的 uid 1000

光是「掛對路徑」還不夠——**私鑰通常是 `0600 root`**（`ca.key` / `cloud_client.key` /
`jwt_private.pem`），而容器內業務進程跑的是 uid 1000，讀不到。原生部署時 BE 以 root
或檔案擁有者執行，從來不會遇到；容器化刻意降權後就撞上。

主機端修正（**只改 owner、不放寬權限模式**，私鑰維持 0600）：

```bash
sudo chown 1000:1000 <憑證目錄>/*.key <憑證目錄>/*.pem
```

**失敗症狀極具誤導性**（2026-08-15 實際踩到，繞了很久）：

- agent **心跳正常**、`last_seen_at` 持續更新 ← 這條只驗 JWT 公鑰，而公鑰是 0644 讀得到
- 只有**健康檢查失敗**（`{"reachable": false, "detail": "[Errno 13] Permission denied"}`）
- 而 FE 的「在線狀態」欄會**以健康檢查結果覆蓋心跳判定**（CM-928 設計），
  於是畫面上兩欄一起顯示離線 → 看起來像「agent 根本沒連上」，
  實際上是它連得好好的、只有一支私鑰讀不到

**裝機驗證**（別等 agent 心跳才發現）：

```bash
# ⚠️ 必須帶 -u 1000：docker exec 預設以 root 進入，root 讀得到 0600 私鑰，
#    用 root 驗會全部 ✅ 卻漏掉這個坑（本案初次驗證即因此漏掉）。
#    要驗的是「業務進程的身分讀不讀得到」，不是「有沒有這個檔」。
docker exec -u 1000 <容器> sh -c 'for p in $AGENT_CA_CERT $AGENT_CA_KEY \
    $AGENT_CLOUD_CLIENT_CERT $AGENT_CLOUD_CLIENT_KEY \
    $AGENT_JWT_PRIVATE_KEY $AGENT_JWT_PUBLIC_KEY; do
  [ -r "$p" ] && echo "✅ $p" || echo "❌ $p"; done'
```

六個全 ✅ 才算好。有 ❌ 的兩種可能：掛載路徑與 `AGENT_*` 對不上（檔案根本不在），
或 owner 不是 1000（檔案在但讀不到）——`ls -ln` 一看便知。

**端到端確認**（最終判準，光看檔案可讀還不夠）：

```bash
curl -sk -X POST -H "Authorization: Bearer <token>" -H 'Content-Type: application/json' \
  -d '{"base_url":"<agent 位址>"}' https://<主機>/api/1.0/remote-agents/health-check
# 期望 {"reachable": true, "status_code": 200}
```

確認 agent 真的通了（心跳間隔預設 `AGENT_HEARTBEAT_INTERVAL_SEC`，等一輪）：

```bash
docker logs --since 5m <容器> | grep 'agents/heartbeat'   # 應見 HTTP 200
```

> `AGENT_AUTH_MODE` 不設＝預設 `none`＝agent 認證整條關閉，此時以上憑證不會被讀。
> 設為 `full` 才會走 mTLS + JWT。

### 🔴 時區：容器必須與主機／DB 一致

image 內已釘死 `TZ=Asia/Taipei`。**不要把它改成與該部署其他元件不同的值。**

原因：程式內有 **naive datetime 比較**的既有寫法，最典型的是 agent 在線判定
（`app/remote_agent/service/agent_enrollment_service.py:is_online()`）：

```python
return (datetime.now() - last_seen_at).total_seconds() < offline_threshold_sec
```

兩邊都是不帶時區的時間值，一旦容器時區與寫入端（DB／其他服務）不同，差幾小時就
永遠算成「很久沒心跳」。

實際症狀（2026-08-15 於 188 踩到，容器初版未設 TZ 落到 UTC、主機是 CST）：

- agent 心跳持續 **HTTP 200**
- DB `last_seen_at` **持續更新**
- **但畫面顯示離線** ← 唯一的異常

也就是說東西全都是活的，只有那個判定壞掉——查的時候很容易往「網路不通／憑證錯」
的方向鑽。**先比對時間再說**：

```bash
date                        # 主機
docker exec <容器> date     # 容器，兩者應完全一致（含時區縮寫）
```

跨時區客戶部署時以 `-e TZ=<該地時區>` 覆寫，但主機、容器、DB 三者要一致。

**owner 不必事先喬**：容器 entrypoint 會在啟動時把**上述掛載點**（且僅限這幾個）
`chown` 成 `1000:1000`（已實測：root 擁有的 bind mount 掛進去後自動修正，上傳正常）。
若以 `docker run --user` 自行指定非 root 身分啟動，則 chown 不會執行，owner 需自備。

> 產物本身（`/app` 下除 `static`／`log` 外的內容）是 `root:root`，業務進程（uid 1000）
> **只讀不寫**——這是刻意的防竄改設計，見下一段。

**`STORAGE_CONFIG` 是 DB 設定不是環境變數**：儲存後端（local / minio / remote_agent）
存在 `public.system_configs`（group=`STORAGE_CONFIG`）。⚠️ 選 `local` 時
`base_dir` **必須明確設定**——未設會 fallback 到 `/tmp/upload/`，重啟即遺失。

### 🔒 建議加上 `--read-only`（防竄改，FR-064 D4）

image 內產物已歸 `root:root`、業務進程跑 uid 1000，**檔案系統層就寫不進產物**。
再加 `--read-only` 把整個 rootfs 掛成唯讀，連 root 身分（例如有人
`docker exec -u 0` 進來）也改不動——這是成本近乎為零的一道防線，正式交付建議都加。

加了之後必須補兩個 tmpfs，否則啟動失敗：

```bash
docker run -d --name guidant-api --restart unless-stopped \
  --read-only \
  --tmpfs /tmp:rw,size=2g \
  --tmpfs /run:rw \
  --env-file /srv/guidant-ai/guidant.env \
  -p 8000:8000 \
  -v /srv/guidant-ai/static:/app/static \
  -v /srv/guidant-ai/log:/app/log \
  -v /srv/guidant-ai/home:/home/guidant \
  -v /srv/guidant-ai/pki:/opt/guidant/pki \
  -v /etc/machine-id:/etc/machine-id:ro \
  -v <憑證目錄>:<憑證目錄>:ro \
  guidant-ai-be:<version>
```

| 項目 | 為什麼 |
|------|--------|
| `--tmpfs /tmp:rw,size=2g` | `TMPDIR=/tmp`，LibreOffice 轉檔中繼要寫。**容量要給足**——大檔轉檔吃掉 tmpfs 會轉檔失敗。若轉檔量大，改掛實體 volume（`-v /srv/guidant-ai/tmp:/tmp`）而非 tmpfs |
| `--tmpfs /run:rw` | gunicorn 等常見的 pid／socket 落點 |
| 既有 volume（含 `/opt/guidant/pki`） | 照舊掛，唯讀化不影響它們（bind mount / volume 不受 `--read-only` 管） |
| `/home/guidant` **一定要掛** | LibreOffice 首次轉檔會寫 `$HOME/.config/libreoffice`。沒掛時該路徑落在唯讀 rootfs 上 → **轉檔失敗** |

compose 寫法：**直接用 repo 內 `docker/production/docker-compose.yml`**（§0），
`read_only` + tmpfs + 全部掛載（含 tamper 標記的 `/opt/guidant/pki`）都已內建。

socketio 容器同樣適用（`RUN_MODE=socketio`，實測兩模式皆 healthy）。

> 不加 `--read-only` 也能跑，**不會有任何錯誤訊息**——這正是要在文件寫明的原因：
> 它是「加了才有、不加也不吭聲」的防護，不會有人在排錯時發現自己漏了。

---

## 5. 功能模組（用到才填，整段可留白）

| 功能 | 變數 |
|------|------|
| AI（Dashboard／翻譯／bot） | `OPENAI_API_KEY`、`ANTHROPIC_API_KEY`、`GOOGLE_API_KEY` |
| Google Drive 整合 | `GOOGLE_DRIVE_OAUTH_CLIENT_ID`、`..._SECRET`、`..._REDIRECT_URI`、`DRIVE_TOKEN_ENCRYPTION_KEY`（⚠️ 每環境獨立生成，換值會導致既有密文解不開需重新授權）、`DRIVE_FILE_SIZE_LIMIT_MB`、`DRIVE_SYNC_WORKER_POOL_SIZE` |
| 檢測 Agent | `AGENT_AUTH_MODE`、`DETECTION_TOOL_ENCRYPTION_KEY`、`AGENT_CA_CERT`／`_KEY`、`AGENT_CLOUD_CLIENT_CERT`／`_KEY`、`AGENT_JWT_PRIVATE_KEY`／`_PUBLIC_KEY`（值是**憑證檔的絕對路徑**，掛載點必須與這些值逐字一致，見 §4）、`AGENT_CLOUD_ENDPOINT`（agent 回連 BE 的位址，每部署點必改）、`AGENT_HEARTBEAT_INTERVAL_SEC`、`AGENT_CERT_VALID_DAYS`（預設 365）、`AGENT_JWT_TTL_SEC`（預設 60）、`DETECTION_UPLOAD_*` 五項與 `DETECTION_PROFILE_*` 四項上傳限制（都有預設，見 `.env.sample`） |
| License Center 對接 | `LICENSE_ACTIVATION_SERVER_URL`（非本機部署**必填**，預設 `http://127.0.0.1:5062` 僅 DEV 可用）、`LICENSE_CENTER_API_TOKEN` |
| 排程報表 | `SCHEDULE_REPORT_DIR`（若使用，該路徑需可寫且應在掛載點內） |
| SSP 匯出轉檔 | `LIBREOFFICE_CMD`（LibreOffice 裝在非標準路徑才需填；容器版 image 內已裝於標準路徑） |

---

## 6. jedi-* 套件內的環境變數（主專案 grep 不到，最容易漏）

這 16 個由內部共用套件直接讀取，不經主專案 config，因此**在主專案原始碼裡搜不到**——
歷來漏設的常客（design 盤點#14）：

| 類別 | 變數 |
|------|------|
| 執行環境 | `ENABLE_MULTI_TENANT`、`RUN_ENV`、`TZ` |
| DB | `DB_USERNAME`、`DB_PASSWORD`、`DB_SCHEMA`、`DEFAULT_SCHEMA` |
| Redis | `REDIS_DB`、`REDIS_SSL`、`REDIS_SECRET`（見下） |
| i18n | `DEFAULT_LOCALE` |
| 外部整合 | `GITLAB_*`、`GITHUB_PRIVATE_TOKEN` |
| Auth | `OTP_COOLDOWN_SECONDS`、`AUTH_CHANGE_SECRET_COOLING_HOURS` |

⚠️ `DB_USERNAME`（jedi 讀）與 `DB_USER`（主專案讀）是**兩個不同的變數名**。
兩邊都用到 DB 帳號時，兩個都要填成同一個值。

⚠️ **jedi-mfa（Email OTP）只認舊 JSON 格式 `REDIS_SECRET`**
（`jedi_mfa/common/util/redis_client_util.py:18` 直接 `json.loads(os.getenv("REDIS_SECRET"))`），
不吃主專案的平鋪 `REDIS_USER` / `REDIS_PASSWORD`。有啟用 Email OTP 的部署，
`REDIS_SECRET` 舊格式必須保留；且主專案「兩種都填時以舊 JSON 為準」，
所以填了就要填真值。

---

## 6.5 build 期簽章（產 release image 時才需要，跑服務不用）

`scripts/build/build_release.sh` 產完 dist 會產 integrity manifest 並送 License Center
簽章。用的環境變數**與產品 BE 既有參數同名**（`config/config.py`，FR-062），不另創新名：

| 變數 | 用途 | 預設 |
|------|------|------|
| `LICENSE_ACTIVATION_SERVER_URL` | LC 簽發站 base URL（送簽打 `/api/internal/sign-manifest`） | `http://127.0.0.1:5062`（build 機 188 上 LC 就在本機） |
| `LICENSE_CENTER_API_TOKEN` | LC `api_tokens` 白名單內的明文 token，**至 LC 後台「API Token 管理」頁建立**；不入版控、不進 build log（腳本只印前 4 碼） | —（必填才會送簽） |

- **無 token 時只產不簽**：`build_release.sh` 印明確警告後繼續，但**沒簽的 dist
  打不出 image**（`build_image.sh` 會擋），所以「忘了簽」不會靜靜流到出貨。
- 手動補簽：
  ```bash
  LICENSE_CENTER_API_TOKEN=… scripts/build/sign_manifest.sh \
      --manifest <dist>/integrity-manifest.json \
      --output   <dist>/integrity-manifest.sig
  ```
- air-gapped／簽發站不可達時的 CLI 保底（在 LC 主機上跑，私鑰不離開該機）：
  `license-center sign-manifest <manifest 檔> --output <sig 檔>`。
  兩條路徑走同一個 sign_payload 與同一道結構守門，產物等價。

---

## 7. 落地版停用的功能

| 功能 | 原因 | 影響面 |
|------|------|--------|
| 證據自動分類（AI 分類器） | 需以 `docker run` 起分類器容器；BE 自己容器化後變成 DinD 問題（要掛 `docker.sock`，等同給容器宿主機 root 權限）。D4 定案降級停用 | 該模組端點仍在，觸發時止於 `EC_500001`「分類容器執行失敗」。**其他模組不受影響** |
| Swagger UI | 從未啟用（`docs.init_app` 一直被註解），且落地版不應對客戶暴露 API 文件 | 無 |

---

## 9. 客戶端主機層留證建議（FR-064 D11，2026-08-16）

產品的防竄改機制（FR-064）能偵測到「檔案 hash 對不上」並留下 `machine_fingerprint`／
`tamper_event_id`／不符檔案的 mtime/ctime/owner/size 等**檔案證據**，但**「是誰在主機上
動的手」不在產品應用層能力範圍內**——竄改動作（SSH 登入改檔、`docker exec` 進容器、
直接改 volume 掛載來源）完全不經過產品的業務邏輯，產品拿不到操作者身分。

**要溯源到「哪個帳號、哪個 session 做的」，需要客戶主機層自行建立審計機制**，
這是客戶責任範疇，不是本產品的責任範疇。以下是建議客戶部署時一併規劃的項目：

### 9.1 `auditd` 監控產物目錄

Linux 主機上用 `auditd` 對容器產物與掛載路徑加寫入監控規則，範例：

```bash
# 監控 /app 底下（產物目錄）的寫入／屬性變更（含 chmod/chown）
auditctl -w /app -p wa -k guidant_integrity

# 監控憑證與 tamper 落點目錄（見 §4）
auditctl -w /opt/guidant/pki -p wa -k guidant_pki_write

# 若採 bind mount，監控主機端實際來源路徑（容器內 /app 對應到的主機路徑）
auditctl -w /srv/guidant-ai/app-image-root -p wa -k guidant_host_write
```

`-p wa` 表示監控 write／attribute-change 事件；`-k` 是自訂 tag，方便後續用
`ausearch -k guidant_integrity` 篩選。規則要設為開機常駐（寫入
`/etc/audit/rules.d/`），否則重開機即失效。

查詢範例（配合 tamper 事件的 `detected_at` 時間窗，往回抓對應的稽核紀錄）：

```bash
ausearch -k guidant_integrity -ts <detected_at 前後時間窗>
```

### 9.2 log 外送建議

主機層審計紀錄（`auditd` log、`docker exec` 使用紀錄、SSH 登入 log）建議外送到
客戶自有的集中式 log 平台（SIEM／syslog server），而非只留在被懷疑遭竄改的
同一台主機上——本地留存的稽核紀錄與本地 tamper 標記檔面臨同一個弱點：
**主機若被完全控制，本地留存的一切都可能被清除或竄改**，外送才能保留不受單機
狀態影響的獨立證據鏈。

### 9.3 明文邊界

> **產品應用層無法識別檔案竄改行為人，主機層審計是客戶責任範疇。**
> 產品偵測與存證止於「檔案證據＋（若有業務 session）旁證」，不含操作者身分溯源；
> 若客戶場景需要「查明是誰做的」這一層，須自行部署並維運上述主機層審計機制。

---

## 10. 交付前檢查清單

- [ ] env 檔無 `export ` 前綴、值無多餘引號（§1）
- [ ] 核心必填齊備（§2：5 項強制 + Redis 帳密 2 項雖未強制仍應填）
- [ ] `DEBUG=false`、`ENV=PRD`、`CORS_ALLOWED_ORIGINS` 已收斂為實際站台（§3）
- [ ] 六個掛載點都掛了，`/tmp` 容量足夠（§4）
- [ ] `JWT_SECRET_KEY` 與各 Fernet 金鑰（Drive／檢測工具）**每客戶獨立生成**
- [ ] 內網環境已評估 Turnstile captcha 是否需停用（§3）
- [ ] 兩個容器起得來：`/api/1.0/version` 200、`/healthz` 200
- [ ] **承載是 gunicorn**：啟動 log 第一行應見 `承載=gunicorn（workers=N）`，且 log 內**不該**出現 `This is a development server`
- [ ] **時區一致**：`date` 與 `docker exec <容器> date` 完全相同（§4 末）
- [ ] **憑證六個檔以 `-u 1000` 驗皆可讀**（§4；用 root 驗會漏掉 owner 問題），且 health-check 回 `reachable: true`、畫面兩欄皆顯示上線
- [ ] 七項探針全過：`scripts/build/probe_container.sh --image <image> --env-file <env>`
- [ ] `docker exec <容器> ps -eo uid,comm` 全為 uid 1000（無 root 進程）
- [ ] **產物不可寫**：`docker exec -u 1000 <容器> sh -c 'echo x >> /app/guidant-ai'` 應回 `Permission denied`（§4 唯讀化）
- [ ] **已加 `--read-only` ＋ 兩個 tmpfs**（§4 唯讀化；不加不會報錯，只能靠這條檢查）
- [ ] 重啟後上傳檔還在（`docker restart` 後檢查 `/app/static`）
