−25%

Windows 年繳方案適用,至 10 月 31 日。 檢視方案

EQVPS
開始使用

用於程式代理預覽的 VPS:代理推送的每個分支都有自己的 URL

程式代理完成一個分支,說它能用。給它一台能驗證這件事的伺服器:每個分支建置成容器,在受密碼保護的獨立 HTTPS 子網域上開啟;代理的 SSH 金鑰只能啟動和停止預覽。

程式代理說功能已經能用了。也許確實如此。它寫的測試都通過了,但這證明的東西比大家希望的少。你真正想要的,是在它接近使用者之前先在瀏覽器裡打開看看,而且不必讓代理碰正式環境。

預覽環境正好解決這個問題,而且不需要什麼平台。一台有獨立 IPv4 的 VPS、Docker、Caddy 和兩個短腳本,就能給每個分支一個自己的 URL,例如 https://feature-login-form.preview.example.com,有密碼保護,每次推送都會重新建置。代理拿到的 SSH 金鑰只能啟動和停止預覽,其他什麼都做不了。

我們在測試伺服器上架好這一套,寫之前完整跑過一遍:建立、開啟、再次推送、拆除、嘗試濫用金鑰。

需要準備什麼

  • 一台有公開 IPv4 的 VPS。AI-Agent-IP 可以同時容納多個預覽。
  • 一筆萬用字元 DNS 記錄:*.preview.example.com → 伺服器 IP。
  • 你的應用程式儲存庫,內含在 8080 連接埠提供服務的 Dockerfile。任何語言都行。
  • 裝好 apt install -y docker.io caddy git 的 Ubuntu 24.04 或 Debian 12/13。

用唯讀的部署金鑰把儲存庫複製到 /srv/app,只需一次。

兩個腳本

/usr/local/bin/preview-up 建置一個分支並上線:

#!/usr/bin/env bash
# preview-up <branch> — build the branch and open it at https://<name>.preview.example.com
set -euo pipefail
BRANCH="$1"
NAME=$(printf '%s' "$BRANCH" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/--*/-/g; s/^-//; s/-$//' | cut -c1-40)
DIR=/srv/previews/$NAME
git -C /srv/app fetch --quiet origin "$BRANCH"
rm -rf "$DIR"; git -C /srv/app worktree prune
git -C /srv/app worktree add --quiet --force --detach "$DIR" FETCH_HEAD
docker build --quiet -t "preview-$NAME" "$DIR" >/dev/null
docker rm -f "preview-$NAME" >/dev/null 2>&1 || true
docker run -d --name "preview-$NAME" --restart unless-stopped --memory 512m -p 127.0.0.1::8080 "preview-$NAME" >/dev/null
PORT=$(docker port "preview-$NAME" 8080/tcp | head -1 | awk -F: '{print $NF}')
cat > "/etc/caddy/previews/$NAME.caddy" <<CADDY
$NAME.preview.example.com {
	import preview_common
	reverse_proxy 127.0.0.1:$PORT
}
CADDY
systemctl reload caddy
echo "https://$NAME.preview.example.com"

/usr/local/bin/preview-down 把它移除:

#!/usr/bin/env bash
# preview-down <branch> — remove the branch preview
set -euo pipefail
NAME=$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9' '-' | sed 's/--*/-/g; s/^-//; s/-$//' | cut -c1-40)
docker rm -f "preview-$NAME" >/dev/null 2>&1 || true
docker image rm "preview-$NAME" >/dev/null 2>&1 || true
rm -f "/etc/caddy/previews/$NAME.caddy"; rm -rf "/srv/previews/$NAME"; git -C /srv/app worktree prune
systemctl reload caddy

裡面有幾處是刻意的。容器只在 127.0.0.1 上以隨機連接埠發布,所以除了經過 Caddy,沒有東西能連到它們。這點很重要,因為 Docker 發布的連接埠會繞過 ufw。每個預覽有 512 MB 記憶體上限,一個失控的分支不會拖垮其他分支。再次執行同一個分支會就地重新建置:推送、執行 up、重新整理。

Caddy:每個預覽一個檔案,所有預覽共用一組密碼

mkdir -p /etc/caddy/previews /srv/previews
chmod +x /usr/local/bin/preview-up /usr/local/bin/preview-down
caddy hash-password --plaintext 'choose-a-review-password'

把雜湊填進 /etc/caddy/Caddyfile:

(preview_common) {
	basicauth {
		reviewer PASTE-THE-HASH-HERE
	}
}
import /etc/caddy/previews/*.caddy

接著執行 systemctl reload caddy。現在所有預覽都在同一個登入後面,帶除錯輸出的半成品分支不會暴露在網際網路上。較新的 Caddy 版本裡這個指令也寫作 basic_auth;basicauth 在兩種版本都還能用。

代理的金鑰:只有 up 和 down

代理需要的是觸發預覽,而不是擁有伺服器。建立一個 deploy 使用者和一個只接受兩道指令的閘道腳本:

#!/usr/bin/env bash
# /usr/local/bin/preview-gate — the only thing the agent's key may run
set -euo pipefail
read -r ACTION BRANCH EXTRA <<< "${SSH_ORIGINAL_COMMAND:-}"
[[ -z "${EXTRA:-}" && "${BRANCH:-}" =~ ^[A-Za-z0-9][A-Za-z0-9._/-]{0,99}$ && "$BRANCH" != *..* ]] || { echo "usage: up|down <branch>" >&2; exit 2; }
case "$ACTION" in
  up)   exec sudo /usr/local/bin/preview-up "$BRANCH" ;;
  down) exec sudo /usr/local/bin/preview-down "$BRANCH" ;;
  *)    echo "usage: up|down <branch>" >&2; exit 2 ;;
esac
useradd -m -s /bin/bash deploy
echo 'deploy ALL=(root) NOPASSWD: /usr/local/bin/preview-up, /usr/local/bin/preview-down' > /etc/sudoers.d/preview
chmod 440 /etc/sudoers.d/preview && visudo -cf /etc/sudoers.d/preview
chmod 755 /usr/local/bin/preview-gate

在 /home/deploy/.ssh/authorized_keys 裡,把代理的公開金鑰綁定到這個腳本:

restrict,command="/usr/local/bin/preview-gate" ssh-ed25519 AAAA... coding-agent

在代理工作的機器上:

ssh deploy@preview.example.com up feature/login-form
# https://feature-login-form.preview.example.com
ssh deploy@preview.example.com down feature/login-form

在我們的測試中,up 建置並發布了分支,新提交後的第二次 up 提供了新版本,down 移除了容器和網站。用這把金鑰嘗試 cat /etc/shadow、up x; id、-x 或 a/../b 這類分支名稱,或是互動式登入,得到的都只有 usage: up|down <branch>。分支名稱必須以字母或數字開頭,而且不能包含 ..。

接上代理

使用 Claude Code 或 Codex 時,在專案的指示檔裡加一行就夠了:

推送分支後,執行 ssh deploy@preview.example.com up <branch>,並把印出的 URL 寫進你的摘要。分支合併或放棄後執行 down。

如果你的 CI 已經在每次推送時執行,就從 CI 呼叫同一道指令,讓金鑰完全不出現在代理的環境裡。這是更嚴格的做法,凡是超出個人專案的情況,我們都會選它。

需要留意的地方

  • 磁碟。 每次建置都會留下映像檔層。每週用 cron 跑一次 docker image prune -f 就能控制;down 已經會刪除預覽自己的映像檔。
  • 沒人關閉的分支。 代理開的分支比人多。用一個 cron 列出超過一週的 preview-* 容器並對它們執行 preview-down,就不會冒出二十個被遺忘的預覽。
  • 機密。 預覽應該使用測試憑證。basic auth 密碼擋得住陌生人,但不代表可以把預覽接到正式資料庫。
  • 建置時間。 映像檔在伺服器上建置,所以重型建置會和正在執行的預覽搶 CPU。如果建置要好幾分鐘,就在 CI 建置,再拉取映像檔。

代理本身不一定要住在這台伺服器上。如果住在上面,請看用於 Claude Code 的 VPS;要讓它自己下單並照看這台伺服器,請把它接上 EQVPS MCP 伺服器。同一台機器也能代管團隊的 MCP 閘道。要在完全沒有伺服器的情況下執行不可信的程式碼,可以用沙箱,而有獨立 IPv4 的方案每月 $8 起。

常見問題

為什麼不讓代理直接部署到正式環境?

因為「測試通過」和「能用」不是同一回事,而且最不該做這個判斷的就是代理。預覽讓人在任何東西到達使用者之前先點開看看,代價只是多一道指令。

選哪個方案?

取決於同時執行多少個預覽。AI-Agent-IP(每月 $15,4 vCPU,4 GB 記憶體,40 GB 磁碟)可以輕鬆容納幾個各自限制 512 MB 的小型網頁應用。最重的是 Docker 建置,映像檔較大時要留意磁碟和記憶體。

需要萬用字元憑證嗎?

不需要。Caddy 會自動為每個預覽子網域單獨取得憑證。只有 DNS 記錄是萬用字元。Let's Encrypt 每個註冊網域每週允許約 50 張新憑證,除非代理建立上百個分支,否則綽綽有餘。

代理能用它的 SSH 金鑰弄壞伺服器嗎?

用這把金鑰不能。它被限制為一道指令,只接受帶安全分支名稱的「up <分支>」或「down <分支>」。shell 存取、額外參數和連接埠轉發都會被拒絕。

NAT 方案可以用嗎?

不行。預覽必須能透過各自子網域的 80 和 443 連接埠存取,這需要公開 IPv4。NAT 方案只轉發 SSH 連接埠。

評論

目前還沒有評論。歡迎成為第一位。

發表評論

評論在顯示前會經過審核。