A coding agent says the feature works. Maybe it does. The tests it wrote pass, which proves less than everyone would like. What you actually want is to open the thing in a browser before it gets anywhere near users, and you want that without asking the agent to touch production.
Preview environments solve that, and you don't need a platform for them. One VPS with its own IPv4, Docker, Caddy and two short scripts give every branch its own URL like https://feature-login-form.preview.example.com, behind a password, rebuilt on every push. The agent gets an SSH key that can start and stop previews and do nothing else.
We built this on a test server and ran through the whole loop before writing it down: create, open, push again, tear down, try to abuse the key.
What you need
- A VPS with a public IPv4. AI-Agent-IP has room for several previews at once.
- A wildcard DNS record:
*.preview.example.com→ the server's IP. - Your app's repository with a
Dockerfilethat serves on port 8080. Any language works. - Ubuntu 24.04 or Debian 12/13 with
apt install -y docker.io caddy git.
Clone the repository once to /srv/app, using a deploy key with read-only access.
The two scripts
/usr/local/bin/preview-up builds a branch and puts it online:
#!/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 removes it:
#!/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
A few choices in there are deliberate. Containers publish only on 127.0.0.1 with a random port, so nothing reaches them except through Caddy. That matters because Docker's published ports bypass ufw. Each preview gets a 512 MB memory cap, so one runaway branch can't take the others down. And running the same branch again rebuilds it in place: push, run up, refresh.
Caddy: one file per preview, one password for all
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'
Put the hash into /etc/caddy/Caddyfile:
(preview_common) {
basicauth {
reviewer PASTE-THE-HASH-HERE
}
}
import /etc/caddy/previews/*.caddy
Then systemctl reload caddy. Every preview is now behind the same login, so a half-finished branch with debug output isn't on the open internet. On newer Caddy releases the directive is also spelled basic_auth; basicauth still works on both.
The agent's key: up and down, nothing else
The agent needs to trigger previews, not to own the server. Create a deploy user and a gate script that accepts exactly two commands:
#!/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
In /home/deploy/.ssh/authorized_keys, pin the agent's public key to that script:
restrict,command="/usr/local/bin/preview-gate" ssh-ed25519 AAAA... coding-agent
From the machine where the agent works:
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
In our test, up built and published the branch, a second up after a new commit served the new version, and down removed the container and the site. Trying cat /etc/shadow, up x; id, branch names like -x or a/../b, or an interactive login with that key all got usage: up|down <branch> and nothing else. A branch name must start with a letter or digit and can't contain ...
Wiring it into the agent
With Claude Code or Codex, a line in the project's instructions file is enough:
After pushing a branch, run
ssh deploy@preview.example.com up <branch>and put the printed URL in your summary. Rundownwhen the branch is merged or abandoned.
If your CI already runs on push, call the same command from there instead and keep the key out of the agent's environment entirely. That's the stricter option, and we'd pick it for anything beyond a personal project.
What to watch
- Disk. Every build leaves image layers.
docker image prune -ffrom a weekly cron keeps it in check;downalready removes the preview's own image. - Branches nobody closes. Agents open more branches than people do. A cron job that lists
preview-*containers older than a week and runspreview-downon them saves you from finding twenty forgotten previews. - Secrets. Previews should run with test credentials. The basic-auth password keeps strangers out; it doesn't make it fine to point a preview at the production database.
- Build time. Images build on the server, so a heavy build competes with running previews for CPU. If builds take minutes, build in CI and pull the image instead.
The agent itself doesn't have to live on this server. If it does, see VPS for Claude Code, and to let it order and look after that server on its own, connect it to the EQVPS MCP server. The same box can also host your team's MCP gateway. For running untrusted code without a server at all there are sandboxes, and the plans with their own IPv4 start at $8 a month.
Comments
No comments yet. Be the first.