Synology NAS用一份 Docker 映像同時跑 Codex CLI 與 Claude Code:從零開始的雙 AI 終端共存教學

作者:

分類:

Docker × AI CLI 實戰.初學者版
一份 Docker 映像,兩套 AI CLI:Codex 與 Claude Code 從零共存

先花五分鐘建立正確的心智模型,再一步步把舊的 Codex Docker 目錄搬移、改名為 codex-claude,用同一份 Dockerfile 裝好兩套 CLI,讓 Codex 走 7681、Claude 走 7682,各自保留登入狀態,並用一個受限的小服務安全地控制其他專案。

1. 這篇文章要幫你完成什麼

你原本已經有一個能用瀏覽器操作 Codex CLI 的 Docker 環境(放在 NAS 的 /volume1/docker/codex)。現在你想在同一套環境裡再加上 Claude Code,而且:

  • 兩套 CLI 各自有獨立的網頁終端,不會互搶畫面;
  • 各自的登入狀態、設定、對話紀錄互不干擾;
  • 維護成本低——升級系統套件或 CLI 時只要改一個地方;
  • 順便把舊目錄改成更貼切的名字 codex-claude,並留好備份可隨時回復。
不需要害怕。整個過程沒有任何「不可逆」的步驟:我們會先完整備份,任何一步出錯都能還原成原本能跑的 Codex 環境(見 第 15 節)。

2. 先建立正確的心智模型

初學者最常見的誤解是:「一個 Docker 跑兩個 CLI」是把 Codex 和 Claude 塞進同一個終端輪流用。不是這樣。

正確的理解是——一份 Dockerfile(食譜)→ 一個 Docker 映像(做好的半成品)→ 由 Compose 啟動成多個容器(服務),每個容器只跑一件事。

三個重點:

  1. 映像只有一份。Codex 和 Claude 裝在同一份映像裡,所以「升級」只發生在一個 Dockerfile。
  2. 容器彼此隔離。codex-web 和 claude-web 是兩個獨立行程,各自綁不同的連接埠、不同的 tmux session、不同的設定資料夾。它們天生不會衝突。
  3. Docker 控制權只給一個「守門員」。AI CLI 不應該直接碰 docker.sock(等於整台主機的 root)。我們把控制權交給一個只會做五件事、只認白名單專案的小服務 project-helper。這是進階但很重要的一環,第 9 節會說明;剛入門可以先跳過。

3. 名詞速查表

看到不懂的字先回來這裡查,不用死背。

Docker 映像 (image)
一個「做好的環境快照」,裡面已經裝好作業系統套件和程式。由 Dockerfile 建置而來。
容器 (container)
把映像實際跑起來的「一個執行中的行程」。同一份映像可以同時跑很多容器。
Docker Compose
用一個 compose.yaml 檔描述「我要哪幾個容器、各自的連接埠與資料夾」,然後一鍵啟動全部。文中每個容器稱為一個服務 (service)。
Volume(掛載)
把 NAS 上的一個資料夾「接進」容器裡的某個路徑。容器重建後資料還在 NAS,所以登入狀態不會不見。寫法是 主機路徑:容器內路徑。
Port(連接埠)對應
寫法 7682:7682,左邊是「你用瀏覽器連的主機埠」,右邊是「容器裡程式監聽的埠」。左邊可以隨意改,右邊我們固定。
ttyd
一個小程式,把「一個終端指令」變成網頁。打開網址就等於打開那個終端。
ttyd -W
-W = 允許在網頁裡「輸入」(可寫)。它不是密碼保護,只是打開鍵盤輸入權限。
tmux
終端多工器。這裡的用途是:即使瀏覽器斷線,CLI 仍在 tmux 裡繼續活著,重新整理網頁就接回原本的畫面。
UID
Linux 使用者的數字編號。NAS 上資料夾的擁有者 UID 必須和容器裡的使用者 UID 一致,容器才寫得進去。本文用 1026。

4. 最終的目錄結構長什麼樣

先看終點,過程就不會迷路。全部檔案都在 NAS 的 /volume1/docker/codex-claude/:

codex-claude/
├── Dockerfile              # 一份食譜,裝 Codex + Claude + 系統工具
├── compose.yaml            # 描述 codex-web / claude-web / project-helper 三個服務
├── .env                    # 連接埠、UID、helper 的密鑰(不進 Git)
├── .gitignore              # 忽略 .env 與兩個設定目錄
├── project-control         # 容器內的友善指令(project-restart blog 等)
├── home/                   # Codex 的設定與登入(掛到容器的 ~/.codex)
│   └── .gitkeep
├── claude-home/            # Claude 的設定與登入(掛到容器的 ~/.claude)
│   └── .gitkeep
└── project-helper/         # 受限的 Docker 控制服務
    ├── Dockerfile
    └── app.py
項目 Codex Claude
Compose 服務名稱 codex-web claude-web
容器內 ttyd Port(固定) 7681 7682
主機 Port(可在 .env 改) TTYD_PORT,預設 7681 CLAUDE_TTYD_PORT,預設 7682
tmux session 名稱 codex claude
NAS 上的設定目錄 ./home ./claude-home
容器內設定路徑 /home/codexuser/.codex /home/codexuser/.claude
登入資料檔 auth.json .credentials.json

5. 步驟 1搬移前:備份舊 Codex 目錄

以下都以 Synology NAS 為例:舊目錄 /volume1/docker/codex,新目錄 /volume1/docker/codex-claude。先看看目前狀態、把展開後的設定存一份,方便之後比對:

cd /volume1/docker/codex
sudo docker compose ps
sudo docker compose config > /tmp/codex-compose-resolved.yaml

接著做一份完整、命名清楚的備份:

cd /volume1/docker
sudo cp -a codex codex-backup-before-claude
備份也是敏感資料。cp -a 會連同 home/ 裡的登入 token 一起複製。這份備份不要放進任何公開 Git 倉庫,也不要上傳到雲端相簿之類的地方。先用 df -h /volume1 確認空間夠。

6. 步驟 2停止服務、搬移並改名

一定要先在舊目錄把 Compose 停掉,再改名。否則執行中的容器還記著舊路徑,會出現各種奇怪錯誤。

cd /volume1/docker/codex
sudo docker compose down

cd /volume1/docker
sudo mv codex codex-claude
cd /volume1/docker/codex-claude

改名後,設定檔裡可能還殘留舊的絕對路徑。用 rg(ripgrep)搜出來,但先排除兩個設定目錄,避免被登入紀錄干擾:

rg -n '/volume1/docker/codex([/" ]|$)' . \
  --glob '!home/**' --glob '!claude-home/**'

把「確定是專案路徑」的那幾行改成 /volume1/docker/codex-claude。

不要無腦全域取代。像套件名稱 @openai/codex、容器裡的 .codex 設定目錄、tmux session 名稱 codex ——這些字裡有 codex,但不是舊專案路徑,動到就壞了。

改名後必做:刪除舊容器,再用新路徑重新建立

Docker 在建立容器當下就把 bind mount 的主機來源路徑記進容器設定。資料夾從 codex 改名為 codex-claude 後,單純執行 docker restart 只會重啟同一個舊容器,不會把掛載來源自動改成新路徑。因此舊容器必須刪除,再由新目錄裡的 Compose 建立新容器。

前面的 docker compose down 正常情況下已經會刪除該 Compose 專案的容器。為避免舊版曾用不同專案名稱、手動 docker run,或先前只做過 stop 而留下殘骸,改名後再檢查一次:

# 先列出可能殘留的舊容器;確認名稱後才刪除
sudo docker ps -a --filter name=codex-web \
  --filter name=codex-cli \
  --filter name=codex-project-helper

# 只刪除確認屬於舊架構的容器;不存在時出現 No such container 可忽略
sudo docker rm -f codex-web codex-cli codex-project-helper
先看清單再刪除。docker rm -f 會強制停止並刪除指定容器,但不會刪除本文使用的主機資料夾。若你的容器名稱不同,請以 docker ps -a 顯示的舊容器名稱為準,不要照抄不相符的名稱。

完成 Compose 與路徑修改後,要用 --force-recreate 從新目錄建立容器,讓 bind mount 真正指向 /volume1/docker/codex-claude:

cd /volume1/docker/codex-claude
sudo docker compose config --quiet
sudo docker compose up -d --build --force-recreate

最後直接查看新容器記錄的掛載來源,不只確認網頁「看起來能開」:

sudo docker inspect codex-web \
  --format '{{range .Mounts}}{{println .Source "→" .Destination}}{{end}}'
sudo docker inspect codex-claude-claude-web \
  --format '{{range .Mounts}}{{println .Source "→" .Destination}}{{end}}'

輸出中的專案來源應是 /volume1/docker/codex-claude,不能再出現舊的 /volume1/docker/codex。

7. 步驟 3一份 Dockerfile 裝兩套 CLI

整個共存架構的核心,就是這份 Dockerfile 的 npm 安裝段——同時裝 OpenAI Codex CLI 與 Anthropic Claude Code:

FROM node:22-bookworm-slim

ARG CODEX_UID=1026
ENV DEBIAN_FRONTEND=noninteractive \
    LANG=C.UTF-8 LC_ALL=C.UTF-8 \
    HOME=/home/codexuser TERM=xterm-256color

# 1) 系統工具:兩套 CLI 都會用到
RUN apt-get update && apt-get install -y --no-install-recommends \
      bash bubblewrap ca-certificates curl git less \
      openssh-client procps ripgrep tmux \
    && rm -rf /var/lib/apt/lists/*

# 2) 一次裝好兩套 AI CLI(本教學用最新版;正式環境建議釘版本)
RUN npm install -g \
      @openai/codex \
      @anthropic-ai/claude-code \
    && npm cache clean --force

# 3) ttyd:把終端變成網頁
RUN curl -fsSL \
      https://github.com/tsl0922/ttyd/releases/download/1.7.7/ttyd.x86_64 \
      -o /usr/local/bin/ttyd \
    && chmod 0755 /usr/local/bin/ttyd

# 4) 建立與 NAS 目錄同 UID 的非 root 使用者
RUN useradd --uid "${CODEX_UID}" --create-home --shell /bin/bash codexuser \
    && mkdir -p /home/codexuser/.codex /workspace \
    && chown -R codexuser:codexuser /home/codexuser /workspace

USER codexuser
WORKDIR /workspace
CMD ["bash"]

因為兩個服務都 build 自這份 Dockerfile,升級系統工具或任一 CLI 只需要改這一處,再重新 build。這就是「一份映像」帶來的維護紅利。

版本可重現性:教學為了拿到最新功能而用最新版。正式環境建議把 @openai/codex、@anthropic-ai/claude-code 釘在測試過的版本號,確認沒問題後再升版重 build。
之後會用到的 project-control:若你要做完整版,Dockerfile 尾端還會 COPY project-control 進映像並建立幾個別名(project-restart 等)。第 9 節會補上這段。

8. 步驟 4最小可行版:Compose 兩個服務

先把「能用」做出來。這個版本只有 codex-web 和 claude-web,沒有 project-helper。對大多數人這樣就夠了。

name: codex-claude

services:
  codex-web:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        CODEX_UID: "${CODEX_UID:-1026}"
    container_name: codex-web
    restart: unless-stopped
    init: true
    stdin_open: true
    tty: true
    working_dir: /workspace
    environment:
      LANG: C.UTF-8
      LC_ALL: C.UTF-8
      TERM: xterm-256color
      HOME: /home/codexuser
    ports:
      - "${TTYD_PORT:-7681}:7681"
    volumes:
      - ./home:/home/codexuser/.codex
      - /volume1/docker/codex-claude:/workspace/codex-claude
    entrypoint:
      - ttyd
      - -W
      - -p
      - "7681"
      - -t
      - "fontSize=18"
      - -t
      - "rendererType=canvas"
      - bash
      - -lc
      - 'exec tmux new-session -A -s codex "codex"'
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -o /dev/null http://127.0.0.1:7681 || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

  claude-web:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        CODEX_UID: "${CODEX_UID:-1026}"
    container_name: codex-claude-claude-web
    restart: unless-stopped
    init: true
    stdin_open: true
    tty: true
    working_dir: /workspace
    environment:
      LANG: C.UTF-8
      LC_ALL: C.UTF-8
      TERM: xterm-256color
      HOME: /home/codexuser
    ports:
      - "${CLAUDE_TTYD_PORT:-7682}:7682"
    volumes:
      - ./claude-home:/home/codexuser/.claude
      - /volume1/docker/codex-claude:/workspace/codex-claude
    entrypoint:
      - ttyd
      - -W
      - -p
      - "7682"
      - -t
      - "fontSize=18"
      - -t
      - "rendererType=canvas"
      - bash
      - -lc
      - 'exec tmux new-session -A -s claude "claude"'
    healthcheck:
      test: ["CMD-SHELL", "curl -sS -o /dev/null http://127.0.0.1:7682 || exit 1"]
      interval: 30s
      timeout: 5s
      retries: 3
      start_period: 20s

逐段看懂這份設定

build.args.CODEX_UID
把 .env 的 UID 傳進 Dockerfile,讓容器使用者和 NAS 資料夾擁有者一致。
init: true / stdin_open / tty
互動式 CLI 需要一個「真的終端」。這三個讓容器提供 TTY 並正確處理 Ctrl+C。
ports
兩個服務左邊主機埠不同(7681 / 7682),右邊容器埠也不同,所以同時開兩個網頁不會撞。
volumes 第一行
關鍵的隔離:Codex 掛 ./home → .codex,Claude 掛 ./claude-home → .claude。各自獨立,這是雙 CLI 能穩定共存的根本原因。
volumes 第二行
把專案本身掛進 /workspace/codex-claude,方便你用 AI 直接改這套設定。要讓兩套 CLI 看到更多程式碼,就在兩個服務都各加一行明確的掛載,例如 - /volume1/docker/blog:/workspace/blog。
entrypoint 最後一行
tmux new-session -A -s codex "codex":開一個叫 codex 的 tmux session 跑 codex;-A 代表「已存在就接回去」。所以瀏覽器重整或短暫斷線後,CLI 不會消失。
healthcheck
Docker 每 30 秒 curl 一次自己的 ttyd 埠,失敗就把容器標記為 unhealthy,方便你用 docker compose ps 一眼看出哪個服務掛了。
不要把整台 NAS 掛進容器。每一個要共用的資料夾都應該在 volumes 明確列出。把 /volume1 或 / 直接交給容器,等於把整台 NAS 的讀寫權交給 AI CLI。

9. 步驟 4+完整版:加入受限的 project-helper

如果你想在 Codex / Claude 裡直接下指令去重啟或重建其他 Compose 專案(例如「幫我 rebuild blog」),那 CLI 就需要 Docker 控制權。直接把 /var/run/docker.sock 掛進 AI 容器很危險——那等於給了整台主機的 root。

解法:多一個只會做五件事、只認白名單的守門員服務。它不對外開埠,只有 Compose 內部網路上的 codex-web / claude-web 連得到。

9-1 project-helper/app.py(守門員本體)

一支極小的 Flask 程式:驗證 Bearer token → 檢查專案在白名單 → 只允許 status / restart / up / rebuild / logs → 在該專案目錄執行對應的 docker compose 指令。

import os, subprocess
from flask import Flask, request, jsonify

app = Flask(__name__)
TOKEN = os.environ["PROJECT_HELPER_TOKEN"]

PROJECTS = {
    "blog": "/projects/blog",
    "umami": "/projects/umami",
    "codex-claude": "/projects/codex-claude",
    # …只放你允許被控制的專案
}
ACTIONS = {
    "restart": ["docker", "compose", "restart"],
    "up":      ["docker", "compose", "up", "-d"],
    "rebuild": ["docker", "compose", "up", "-d", "--build"],
    "status":  ["docker", "compose", "ps"],
    "logs":    ["docker", "compose", "logs", "--tail", "200"],
}

@app.post("/action")
def action():
    if request.headers.get("Authorization", "") != f"Bearer {TOKEN}":
        return jsonify(error="unauthorized"), 401
    data = request.get_json(silent=True) or {}
    project, name = data.get("project", ""), data.get("action", "")
    if project not in PROJECTS:
        return jsonify(error="project not allowed", allowed=sorted(PROJECTS)), 400
    if name not in ACTIONS:
        return jsonify(error="action not allowed", allowed=sorted(ACTIONS)), 400
    p = subprocess.run(ACTIONS[name], cwd=PROJECTS[project],
                       text=True, capture_output=True,
                       timeout=1800 if name in {"rebuild", "up"} else 120)
    return jsonify(ok=(p.returncode == 0), project=project, action=name,
                   stdout=p.stdout[-20000:], stderr=p.stderr[-20000:]), \
           (200 if p.returncode == 0 else 500)

9-2 project-helper/Dockerfile

FROM docker:27-cli
RUN apk add --no-cache python3 py3-pip \
    && pip3 install --break-system-packages --no-cache-dir flask gunicorn
WORKDIR /app
COPY app.py /app/app.py
EXPOSE 8080
CMD ["gunicorn", "--bind", "0.0.0.0:8080", "--workers", "1", "--threads", "4", "app:app"]

9-3 project-control(放進主 Dockerfile 的友善指令)

在第 7 節的 Dockerfile USER codexuser 之前加入:

COPY project-control /usr/local/bin/project-control
RUN chmod 0755 /usr/local/bin/project-control \
    && for a in restart up rebuild status logs; do \
         ln -s /usr/local/bin/project-control /usr/local/bin/project-$a; \
       done

project-control 本身是一支小 shell script,把 project-restart blog 轉成一個帶 token 的 HTTP 請求送給 helper:

#!/bin/sh
set -eu
PROG="$(basename "$0")"
case "$PROG" in
  project-restart) set -- restart "${1:-}" ;;
  project-up)      set -- up "${1:-}" ;;
  project-rebuild) set -- rebuild "${1:-}" ;;
  project-status)  set -- status "${1:-}" ;;
  project-logs)    set -- logs "${1:-}" ;;
esac
ACTION="${1:-}"; PROJECT="${2:-}"
URL="${PROJECT_HELPER_URL:-http://project-helper:8080}"
TOKEN="${PROJECT_HELPER_TOKEN:?PROJECT_HELPER_TOKEN is not set}"
curl -fsS -X POST \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  --data "{\"action\":\"$ACTION\",\"project\":\"$PROJECT\"}" \
  "$URL/action"
echo

9-4 在 compose.yaml 加入 helper,並讓兩個 CLI 服務認得它

在 codex-web 和 claude-web 的 environment 各加兩行,並加上 depends_on:

    environment:
      # …原本的 LANG / TERM / HOME …
      PROJECT_HELPER_URL: http://project-helper:8080
      PROJECT_HELPER_TOKEN: "${PROJECT_HELPER_TOKEN}"
    depends_on:
      - project-helper

然後新增第三個服務:

  project-helper:
    build:
      context: ./project-helper
      dockerfile: Dockerfile
    container_name: codex-project-helper
    restart: unless-stopped
    environment:
      PROJECT_HELPER_TOKEN: "${PROJECT_HELPER_TOKEN}"
    volumes:
      # 只有守門員拿到 Docker 控制權
      - /var/run/docker.sock:/var/run/docker.sock
      # 只掛你允許被控制的 Compose 專案目錄
      - /volume1/docker/blog:/projects/blog
      - /volume1/docker/umami:/projects/umami
      - /volume1/docker/codex-claude:/projects/codex-claude
    # 不對外開 port:只有這個 Compose 網路上的服務連得到
    expose:
      - "8080"
絕不要把 /var/run/docker.sock 掛進 codex-web 或 claude-web。整個設計的意義就是:Docker 控制權只存在於 project-helper 這一個容器,而且它被 token、白名單專案、白名單動作三重限制。

10. 步驟 5Port 設計與 .env

容器裡的 ttyd 埠固定是 7681 / 7682;你能自由更動的是「主機端」的埠。全部集中寫在 .env:

CODEX_UID=1026
TTYD_PORT=7681
CLAUDE_TTYD_PORT=7682
# 用 openssl rand -hex 32 產生一長串隨機值貼在這裡(完整版才需要)
PROJECT_HELPER_TOKEN=請換成你自己的隨機字串

假設 NAS 上 7682 已經被別的服務佔用,你只要把 CLAUDE_TTYD_PORT 改成沒被用的埠(例如 8765),然後 docker compose up -d。容器內的 ttyd 仍然是 7682,不用動 compose.yaml。

產生 helper 密鑰:

openssl rand -hex 32
.env 一定要進 .gitignore。裡面的 PROJECT_HELPER_TOKEN 一旦外洩,任何能連到 helper 的人都能重建你的專案。

11. 步驟 6登入資料分離與持久化

雙 CLI 能穩定共存的第二個要點:兩套認證資料夾完全分開。

./home        → /home/codexuser/.codex    (Codex:auth.json)
./claude-home → /home/codexuser/.claude   (Claude:.credentials.json)

先建好目錄,並把擁有者設成和 CODEX_UID 一致,容器才寫得進去:

cd /volume1/docker/codex-claude
mkdir -p home claude-home
sudo chown -R 1026:1026 home claude-home
touch home/.gitkeep claude-home/.gitkeep

對應的 .gitignore(兩個設定目錄都要忽略):

.env
home/*
!home/.gitkeep
claude-home/*
!claude-home/.gitkeep

這次踩到的坑:Claude 的 ~/.claude.json 在 .claude/ 外面。Claude Code 會把「專案信任狀態、初次設定、MCP 設定」寫在 /home/codexuser/.claude.json,而我們只掛了 .claude/ 目錄。症狀是:每次 --build 重建後,Claude 又要你重新確認信任這個資料夾。若想連這個也保留,先建好檔案再多掛一行:

# 主機端先建立一個空 JSON,避免 Docker 把它當資料夾掛
touch /volume1/docker/codex-claude/claude-home/dot-claude.json
sudo chown 1026:1026 /volume1/docker/codex-claude/claude-home/dot-claude.json

然後在 claude-web 的 volumes 加:

      - ./claude-home/dot-claude.json:/home/codexuser/.claude.json

Codex 對應的檔案(~/.codex/ 內的 config.toml、auth.json)都在掛載目錄裡,沒有這個問題。

切勿把認證目錄公開。home/、claude-home/、.env 都要在 .gitignore;也不要在文章、截圖或貼除錯輸出時露出 token。

12. 步驟 7建置並啟動

先讓 Compose 檢查 YAML 語法,再 build 並啟動:

cd /volume1/docker/codex-claude
sudo docker compose config --quiet      # 沒輸出就是語法正確
sudo docker compose up -d --build --force-recreate
sudo docker compose ps

第一次 build 會下載基礎映像和 npm 套件,需要幾分鐘。完成後開瀏覽器:

  • Codex:http://NAS-IP:7681
  • Claude:http://NAS-IP:7682(或你在 .env 改的埠)
成功長相:docker compose ps 裡 codex-web、claude-web(有做完整版還有 codex-project-helper)狀態都是 Up,且前兩者是 (healthy)。兩個網址都能打開看到各自的 CLI 歡迎畫面。

13. 驗證、首次登入與日常操作

確認兩套 CLI 都在

sudo docker compose exec codex-web  codex  --version
sudo docker compose exec claude-web claude --version

確認健康狀態與埠

sudo docker compose ps
curl -I http://127.0.0.1:7681
curl -I http://127.0.0.1:7682

首次登入

各自打開網頁終端,依 CLI 畫面完成官方登入/授權。登入結果會分別寫進 ./home/auth.json 與 ./claude-home/.credentials.json,之後重建容器不用重登(.claude.json 的例外見第 11 節)。

Claude Code CLI 登入界面
Claude Code CLI 登入界面

看日誌、重啟單一服務

sudo docker compose logs --tail 100 codex-web
sudo docker compose logs --tail 100 claude-web

# 只重啟 Claude,不打擾正在工作的 Codex
sudo docker compose restart claude-web
# 只重建 Claude(例如你改了 Dockerfile 想升級)
sudo docker compose up -d --build claude-web

完整版:在 CLI 裡控制其他專案

在 Codex 或 Claude 的終端裡:

project-status  blog
project-logs    blog
project-restart blog
project-rebuild blog

這些指令會透過 project-helper 執行,且只對白名單專案有效。

14. 安全性:ttyd -W 不等於登入保護

-W 只是「允許在網頁裡打字」,沒有任何密碼驗證。把 7681/7682 直接開到公網,等於把一個能執行任意指令的終端送給全世界。

  • 只在可信任的 LAN、VPN 或 Tailscale 網路裡使用。
  • 需要外網存取時,前面放反向代理,加上 HTTPS 與可靠的身分驗證(不是 ttyd 內建的 basic auth 就好,建議用代理層的 SSO 或至少強密碼)。
  • 防火牆只放行必要來源,不要做無限制的 Port Forwarding。
  • 容器用非 root 使用者(codexuser),只掛兩套 CLI 真正需要的專案目錄。
  • Docker 控制權只給 project-helper,並用 token+白名單專案+白名單動作三重把關。
Dockerfile 裡的 bubblewrap 讓 Codex 能在容器內再做一層 sandbox。若你要放寬 Codex 的權限(例如 codex --sandbox danger-full-access),務必先確認上面的網路隔離都到位。

15. 故障排除與安全回復

Claude 的網址(7682)打不開

sudo docker compose ps
sudo docker compose logs --tail 200 claude-web
sudo ss -lntp | grep ':7682'

若主機埠被別的程式佔用,改 .env 的 CLAUDE_TTYD_PORT 後 docker compose up -d。

設定目錄寫不進去 / 權限錯誤

幾乎都是 NAS 目錄擁有者和容器 UID 不一致。確認 .env 的 CODEX_UID,只針對兩個設定目錄修權限,不要對整個專案 chmod 777:

sudo chown -R 1026:1026 /volume1/docker/codex-claude/home \
                        /volume1/docker/codex-claude/claude-home

重建後 Claude 又要求信任資料夾

~/.claude.json 沒有被保留,見第 11 節的多掛一行做法。

搬移後容器掛到空目錄

檢查 compose.yaml 裡所有舊絕對路徑是否都更新成 codex-claude,再重建:

sudo docker compose down
sudo docker compose up -d --build

project-control 回 401 / 403

codex-web/claude-web 的 PROJECT_HELPER_TOKEN 和 project-helper 的不一致,或 .env 沒被讀到。三個服務要用同一個值;改完 .env 要 docker compose up -d 讓環境變數重新注入。

整組回復成原本的 Codex 環境

先停新服務,把有問題的新目錄留著除錯,再從備份還原:

cd /volume1/docker/codex-claude
sudo docker compose down

cd /volume1/docker
sudo mv codex-claude codex-claude-failed
sudo cp -a codex-backup-before-claude codex
cd codex
sudo docker compose up -d
回復前先確認:備份目錄確實存在、目的地 codex 尚未存在。不要直接刪掉失敗版本——裡面可能有搬移後才產生、還沒備份的登入資料或設定變更。

16. 完成檢查清單與架構總結

  • 舊 codex 目錄已備份為 codex-backup-before-claude,並改名為 codex-claude。
  • Dockerfile 同時安裝 @openai/codex 與 @anthropic-ai/claude-code,兩服務共用這一份映像。
  • codex-web 在 7681、claude-web 在 7682,docker compose ps 都是 Up (healthy)。
  • Codex 用 ./home、Claude 用 ./claude-home;重建容器後兩者都不用重新登入。
  • (完整版)project-helper 沒有對外開埠,只有它掛 docker.sock,且限定白名單專案與五個動作。
  • .env、home/、claude-home/ 都在 .gitignore 裡。
  • 外部存取由 VPN、防火牆或帶驗證的 HTTPS 反向代理保護,沒有裸奔到公網。

這套設計真正的重點不是「把兩個互動式 CLI 擠進一個終端」,而是:用同一份映像壓低維護成本,用服務隔離消除連接埠、tmux session 與認證的衝突,再用一個受限的守門員服務把危險的 Docker 控制權收斂到一處。日後要升級任一 CLI,只要改 Dockerfile、重新 build,再視需要重建單一服務即可——另一套完全不受影響。

這篇文章已有 22 次瀏覽

留言

發佈留言

發佈留言必須填寫的電子郵件地址不會公開。 必填欄位標示為 *