コーディングエージェントが「機能は動きます」と言っています。たぶん本当でしょう。エージェントが書いたテストは通っていますが、それが証明することは誰もが望むより少ないものです。本当にやりたいのは、ユーザーに近づく前にブラウザで開いて確かめること。それも、エージェントに本番を触らせずにです。
プレビュー環境がそれを解決します。そのためのプラットフォームは要りません。専用IPv4付きのVPS 1台、Docker、Caddy、短いスクリプト2本で、各ブランチにhttps://feature-login-form.preview.example.comのような専用URLが付きます。パスワードで保護され、プッシュのたびに再ビルドされます。エージェントが受け取る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にクローンしておきます。
2本のスクリプト
/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:プレビューごとに1ファイル、パスワードは全体で1つ
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ユーザーと、ちょうど2つのコマンドだけを受け付けるゲートスクリプトを作ります:
#!/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がブランチをビルドして公開し、新しいコミット後の2回目のupで新バージョンが表示され、downでコンテナとサイトが消えました。この鍵でcat /etc/shadow、up x; id、-xやa/../bのようなブランチ名、対話ログインを試しても、返ってくるのはusage: up|down <branch>だけでした。ブランチ名は英字か数字で始まる必要があり、..を含めることはできません。
エージェントへの組み込み
Claude CodeやCodexなら、プロジェクトの指示ファイルに1行足すだけです:
ブランチをプッシュしたら
ssh deploy@preview.example.com up <branch>を実行し、表示されたURLを要約に含めること。ブランチがマージされるか破棄されたらdownを実行すること。
CIがすでにプッシュごとに動いているなら、同じコマンドをCIから呼び、鍵はエージェントの環境から完全に外してください。こちらのほうが厳格で、個人プロジェクトを超えるものなら私たちはこちらを選びます。
気をつけること
- ディスク。 ビルドのたびにイメージのレイヤーが残ります。週1回のcronで
docker image prune -fを回せば抑えられます。downはプレビュー自身のイメージをすでに削除します。 - 誰も閉じないブランチ。 エージェントは人より多くブランチを開きます。1週間より古い
preview-*コンテナを列挙してpreview-downをかけるcronがあれば、忘れられたプレビュー20個に出くわさずに済みます。 - シークレット。 プレビューはテスト用の認証情報で動かすべきです。basic authのパスワードは部外者を締め出しますが、プレビューを本番データベースにつないでよい理由にはなりません。
- ビルド時間。 イメージはサーバー上でビルドされるので、重いビルドは動作中のプレビューとCPUを奪い合います。ビルドに数分かかるなら、CIでビルドしてイメージをpullしてください。
エージェント自体がこのサーバーに住む必要はありません。住むならClaude Code向けVPSを参照してください。そのサーバーの注文と管理をエージェント自身にさせるなら、EQVPSのMCPサーバーに接続しましょう。同じマシンでチームのMCPゲートウェイを動かすこともできます。サーバーなしで信頼できないコードを動かすならサンドボックスがあり、専用IPv4付きプランは月$8からです。
コメント
まだコメントはありません。最初になりましょう。