코딩 에이전트가 기능이 동작한다고 말합니다. 정말 그럴 수도 있죠. 에이전트가 쓴 테스트는 통과하지만, 그게 증명하는 건 모두가 바라는 것보다 적습니다. 정말 원하는 건 사용자 근처에 가기 전에 브라우저에서 열어 보는 것이고, 그것도 에이전트에게 프로덕션을 건드리라고 하지 않고서요.
프리뷰 환경이 이 문제를 풀어 주며, 이를 위한 플랫폼은 필요 없습니다. 전용 IPv4가 있는 VPS 한 대와 Docker, Caddy, 짧은 스크립트 두 개면 브랜치마다 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에 한 번 클론해 둡니다.
두 개의 스크립트
/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에서 호출하고, 키는 에이전트 환경에서 완전히 빼 두세요. 더 엄격한 방법이며, 개인 프로젝트를 넘는 일이라면 저희는 이쪽을 고르겠습니다.
주의할 점
- 디스크. 빌드할 때마다 이미지 레이어가 남습니다. 주 1회 cron으로
docker image prune -f를 돌리면 관리됩니다.down은 프리뷰 자체 이미지를 이미 지웁니다. - 아무도 닫지 않는 브랜치. 에이전트는 사람보다 브랜치를 많이 엽니다. 일주일 넘은
preview-*컨테이너를 찾아preview-down을 실행하는 cron이 있으면, 잊힌 프리뷰 스무 개를 발견할 일이 없습니다. - 비밀 정보. 프리뷰는 테스트용 자격 증명으로 돌려야 합니다. basic auth 비밀번호는 낯선 사람을 막아 주지만, 프리뷰를 프로덕션 데이터베이스에 연결해도 된다는 뜻은 아닙니다.
- 빌드 시간. 이미지는 서버에서 빌드되므로 무거운 빌드는 돌아가는 프리뷰와 CPU를 다툽니다. 빌드에 몇 분씩 걸린다면 CI에서 빌드하고 이미지를 받아 오세요.
에이전트 자체가 이 서버에 살 필요는 없습니다. 산다면 Claude Code용 VPS를 참고하세요. 그 서버를 에이전트가 직접 주문하고 관리하게 하려면 EQVPS MCP 서버에 연결하세요. 같은 머신에서 팀의 MCP 게이트웨이를 돌릴 수도 있습니다. 서버 없이 신뢰할 수 없는 코드를 돌리려면 샌드박스가 있고, 전용 IPv4 요금제는 월 $8부터입니다.
댓글
아직 댓글이 없습니다. 첫 번째가 되세요.