Een coding-agent zegt dat de feature werkt. Misschien klopt dat. De tests die hij schreef slagen, en dat bewijst minder dan iedereen zou willen. Wat je eigenlijk wilt, is het ding in een browser openen voordat het ergens bij gebruikers in de buurt komt, zonder de agent aan productie te laten komen.
Preview-omgevingen lossen dat op, en je hebt er geen platform voor nodig. Eén VPS met eigen IPv4, Docker, Caddy en twee korte scripts geven elke branch een eigen URL zoals https://feature-login-form.preview.example.com, achter een wachtwoord, bij elke push opnieuw gebouwd. De agent krijgt een SSH-sleutel die previews kan starten en stoppen en verder niets.
We bouwden dit op een testserver en liepen de hele cyclus door voordat we het opschreven: aanmaken, openen, opnieuw pushen, afbreken, proberen de sleutel te misbruiken.
Wat je nodig hebt
- Een VPS met een publiek IPv4-adres. AI-Agent-IP heeft ruimte voor meerdere previews tegelijk.
- Een wildcard-DNS-record:
*.preview.example.com→ het IP van de server. - De repository van je app met een
Dockerfiledie op poort 8080 serveert. Elke taal werkt. - Ubuntu 24.04 of Debian 12/13 met
apt install -y docker.io caddy git.
Clone de repository één keer naar /srv/app, met een deploy key met alleen leesrechten.
De twee scripts
/usr/local/bin/preview-up bouwt een branch en zet hem 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 haalt hem weg:
#!/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
Een paar keuzes zijn bewust gemaakt. Containers publiceren alleen op 127.0.0.1 met een willekeurige poort, dus niets bereikt ze behalve via Caddy. Dat is belangrijk, want door Docker gepubliceerde poorten omzeilen ufw. Elke preview krijgt een geheugenlimiet van 512 MB, zodat één ontspoorde branch de andere niet meesleurt. En dezelfde branch opnieuw draaien bouwt hem ter plekke opnieuw: push, up draaien, verversen.
Caddy: één bestand per preview, één wachtwoord voor allemaal
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'
Zet de hash in /etc/caddy/Caddyfile:
(preview_common) {
basicauth {
reviewer PASTE-THE-HASH-HERE
}
}
import /etc/caddy/previews/*.caddy
Daarna systemctl reload caddy. Elke preview zit nu achter dezelfde login, dus een half afgemaakte branch met debug-uitvoer staat niet open op internet. In nieuwere Caddy-versies wordt de directive ook basic_auth gespeld; basicauth werkt in beide nog.
De sleutel van de agent: up en down, verder niets
De agent moet previews kunnen starten, niet de server bezitten. Maak een gebruiker deploy en een gate-script dat precies twee commando's accepteert:
#!/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
Koppel in /home/deploy/.ssh/authorized_keys de publieke sleutel van de agent aan dat script:
restrict,command="/usr/local/bin/preview-gate" ssh-ed25519 AAAA... coding-agent
Vanaf de machine waar de agent werkt:
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 onze test bouwde en publiceerde up de branch, een tweede up na een nieuwe commit serveerde de nieuwe versie, en down verwijderde de container en de site. Pogingen met cat /etc/shadow, up x; id, branchnamen als -x of a/../b, of een interactieve login met die sleutel kregen allemaal alleen usage: up|down <branch>. Een branchnaam moet met een letter of cijfer beginnen en mag geen .. bevatten.
Aansluiten op de agent
Met Claude Code of Codex is één regel in het instructiebestand van het project genoeg:
Na het pushen van een branch voer je
ssh deploy@preview.example.com up <branch>uit en zet je de getoonde URL in je samenvatting. Voerdownuit als de branch gemerged of opgegeven is.
Draait je CI al bij elke push, roep dan hetzelfde commando daarvandaan aan en houd de sleutel helemaal uit de omgeving van de agent. Dat is de strengere optie, en die zouden wij kiezen voor alles wat verder gaat dan een persoonlijk project.
Waar je op moet letten
- Schijf. Elke build laat image-layers achter. Een wekelijkse cron met
docker image prune -fhoudt dat in toom;downverwijdert het eigen image van de preview al. - Branches die niemand sluit. Agents openen meer branches dan mensen. Een cronjob die
preview-*-containers ouder dan een week opsomt en erpreview-downop draait, bespaart je twintig vergeten previews. - Geheimen. Previews horen met testgegevens te draaien. Het basic-auth-wachtwoord houdt vreemden buiten; het maakt het niet goed om een preview op de productiedatabase te laten wijzen.
- Buildtijd. Images worden op de server gebouwd, dus een zware build concurreert om CPU met draaiende previews. Duren builds minuten, bouw dan in CI en haal het image op.
De agent zelf hoeft niet op deze server te wonen. Doet hij dat wel, lees dan VPS voor Claude Code, en om hem die server zelf te laten bestellen en onderhouden, koppel hem aan de EQVPS MCP-server. Dezelfde machine kan ook de MCP-gateway van je team hosten. Voor onbetrouwbare code zonder server zijn er sandboxes, en de pakketten met eigen IPv4 beginnen bij $8 per maand.
Reacties
Nog geen reacties. Wees de eerste.