−25%

on annual Windows plans, until 31 Oct. See plans

EQVPS
Get started

VPS for coding-agent previews: every branch the agent pushes gets its own URL

A coding agent finishes a branch and says it works. Give it a server where that claim can be checked: each branch builds into a container and opens at its own HTTPS subdomain behind a password, and the agent's SSH key can do nothing except start and stop previews.

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 Dockerfile that 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. Run down when 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 -f from a weekly cron keeps it in check; down already 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 runs preview-down on 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.

Ready to deploy? Pay with crypto, no KYC — live in about a minute.

Deploy now →

FAQ

Why not let the agent deploy straight to production?

Because 'the tests pass' and 'it works' aren't the same thing, and the agent is the last one who should decide. A preview gives a person something to click before anything reaches users, at the cost of one extra command.

Which plan fits?

It depends on how many previews run at once. AI-Agent-IP ($15/mo, 4 vCPU, 4 GB RAM, 40 GB disk) comfortably holds several small web apps with a 512 MB cap each. Docker builds are the heaviest part, so watch disk and memory if your images are large.

Do I need a wildcard certificate?

No. Caddy gets a separate certificate for each preview subdomain on its own. Only the DNS record is a wildcard. Let's Encrypt allows around 50 new certificates per registered domain per week, which is plenty unless the agent creates hundreds of branches.

Can the agent break the server with its SSH key?

Not through that key. It is restricted to one command that accepts only 'up <branch>' or 'down <branch>' with a safe branch name. Shell access, extra arguments and port forwarding are refused.

Can this run on a NAT plan?

No. Previews must be reachable on ports 80 and 443 at their own subdomains, which needs a public IPv4. NAT plans forward only the SSH port.

Comments

No comments yet. Be the first.

Leave a comment

Comments are moderated before they appear.