−25%

Windows 年払い、10月31日まで。 プランを見る

EQVPS
始める

コーディングエージェントのプレビュー用VPS:エージェントがプッシュしたブランチごとに専用URL

コーディングエージェントがブランチを仕上げ、動くと言っています。それを確かめられるサーバーを用意しましょう。各ブランチはコンテナにビルドされ、パスワード付きの専用HTTPSサブドメインで開きます。エージェントのSSH鍵でできるのはプレビューの起動と停止だけです。

コーディングエージェントが「機能は動きます」と言っています。たぶん本当でしょう。エージェントが書いたテストは通っていますが、それが証明することは誰もが望むより少ないものです。本当にやりたいのは、ユーザーに近づく前にブラウザで開いて確かめること。それも、エージェントに本番を触らせずにです。

プレビュー環境がそれを解決します。そのためのプラットフォームは要りません。専用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からです。

FAQ

エージェントに本番へ直接デプロイさせないのはなぜですか?

「テストが通る」と「動く」は同じではなく、それを判断するのはエージェントが最後であるべきだからです。プレビューがあれば、何かがユーザーに届く前に人が実際に触って確かめられます。コストはコマンド1つ分だけです。

どのプランが合いますか?

同時に動かすプレビューの数によります。AI-Agent-IP(月$15、4 vCPU、4 GB RAM、40 GBディスク)なら、512 MB制限の小さなWebアプリを複数余裕で動かせます。一番重いのはDockerのビルドなので、イメージが大きい場合はディスクとメモリに注意してください。

ワイルドカード証明書は必要ですか?

いいえ。Caddyがプレビューのサブドメインごとに証明書を自動で取得します。ワイルドカードなのはDNSレコードだけです。Let's Encryptは登録ドメインあたり週に約50件の新規証明書を発行でき、エージェントが何百もブランチを作らない限り十分です。

エージェントがSSH鍵でサーバーを壊すことはありますか?

その鍵では無理です。安全なブランチ名を伴う「up <ブランチ>」か「down <ブランチ>」しか受け付けない1つのコマンドに制限されています。シェルアクセス、追加の引数、ポートフォワードは拒否されます。

NATプランで動きますか?

いいえ。プレビューは独自のサブドメインでポート80と443から到達できる必要があり、そのためには公開IPv4が必要です。NATプランが転送するのはSSHポートだけです。

コメント

まだコメントはありません。最初になりましょう。

コメントを残す

コメントは表示される前にモデレートされます。