Create and destroy preview servers per pull request

Order a VPSNine server per pull request from GitHub Actions, deploy the branch, comment its address, and delete the server when the PR closes.

Tested on Ubuntu 24.04 LTS

Before you start

  • A GitHub repository whose build writes static files to dist/
  • A read-and-write VPSNine API token
  • Account credit to pay for the servers
  • The deploy script from the deploy-on-push tutorial

To give each pull request its own preview server, have a GitHub Actions workflow order a VPSNine server through the API when the PR opens, set it up with a first-boot script, deploy the branch to it on every push, comment its address on the PR, and delete the server when the PR closes. This tutorial builds that workflow with two small shell scripts.

What we tested: both workflows pass actionlint and the scripts pass shellcheck. The order, find, tag and delete scripts ran against a local copy of the VPSNine API, including running the order step twice for one PR (it found the server instead of ordering again). The first-boot script ran twice on an Ubuntu 24.04 server with systemd. GitHub's own runners weren't part of the test.

Know what it costs first

VPSNine bills by the month, not by the hour. Each preview server is a new order, and the order's invoice covers a month of the plan you pick; deleting the server when the PR closes stops it but doesn't refund the unused days. With the Budget plan, every pull request costs a month of Budget, whether it stays open for a day or for three weeks.

That suits teams with a few long-lived pull requests. If you open many short ones, keep a small pool of servers and redeploy to a free one instead; the end of this tutorial shows how.

The workflow pays from account credit: an order that credit covers is paid at once and built straight away. Top up under Billing before you start (how). If credit runs out, the order step stops with the invoice's address rather than leaving an unpaid order behind. An account can have 3 unpaid orders at a time and place 10 orders an hour.

What you need on the account

  1. A read-and-write API token for this workflow alone.
  2. An SSH key pair for the workflow, with the public half added to your account under SSH keys and named ci-deploy. New servers get it for root, so the workflow can log in. Make it with ssh-keygen -t ed25519 -N "" -C ci-deploy -f ci_deploy_key.
  3. In the repository's Settings > Secrets and variables > Actions, two secrets: VPS9_TOKEN (the token) and DEPLOY_SSH_KEY (the private half, ci_deploy_key).

The first-boot script

Each preview server is ordered with nginx and rsync as extra packages and this setup script, which runs once on the first boot. Save it as scripts/preview-setup.sh:

bash
#!/bin/bash
# Runs once on the preview server's first boot (the order's setupScript): nginx serving /var/www/app/current.
set -euo pipefail
install -d /var/www/app/releases
cat > /etc/nginx/sites-available/app <<'NGINX'
server {
    listen 80 default_server;
    listen [::]:80 default_server;
    root /var/www/app/current;
    index index.html;
    location / {
        try_files $uri $uri/ =404;
    }
}
NGINX
ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
rm -f /etc/nginx/sites-enabled/default
systemctl enable --now nginx
systemctl reload nginx

When it finishes, the server writes its exit code to /var/lib/vps9-setup.exit. The workflow waits for that file before it deploys.

Order or find the server

scripts/preview-up.sh takes the PR number. If a server called pr-<number>-preview exists, it uses it; otherwise it orders one, tags it preview and pr-<number>, and waits until it runs. It prints the server's IPv4 address:

bash
#!/usr/bin/env bash
# scripts/preview-up.sh PR_NUMBER: makes sure pr-<n>-preview exists and is running; prints its IPv4 address.
set -euo pipefail
: "${VPS9_TOKEN:?set VPS9_TOKEN}"
API="${VPS9_API_URL:-https://my.vpsnine.com/api/v1}"
pr="$1"
host="pr-$pr-preview"

api() {
  local path=$1; shift
  curl -sS --fail-with-body -H "Authorization: Bearer $VPS9_TOKEN" "$@" "$API$path"
}
find_server() { api /servers | jq -r --arg h "$host" '.data[] | select(.hostname == $h) | .id'; }

id="$(find_server)"
if [ -z "$id" ]; then
  key_id="$(api /ssh-keys | jq -er '.data[] | select(.name == "ci-deploy") | .id')"
  order="$(jq -n --arg h "$host" --arg k "$key_id" --rawfile s scripts/preview-setup.sh \
    '{plan: "budget", image: "ubuntu-24.04", hostname: $h, sshKeyIds: [$k], packages: ["nginx", "rsync"], setupScript: $s}' |
    api /servers -X POST -H "Content-Type: application/json" \
      -H "Idempotency-Key: preview-pr$pr-run${GITHUB_RUN_ID:-local}-${GITHUB_RUN_ATTEMPT:-1}" --data @-)"
  status="$(jq -r .invoice.status <<<"$order")"
  if [ "$status" != "paid" ]; then
    echo "The order wasn't covered by account credit: pay $(jq -r .payUrl <<<"$order") or top up first." >&2
    exit 1
  fi
  for _ in $(seq 1 24); do
    id="$(find_server)"
    [ -n "$id" ] && break
    sleep 5
  done
  [ -n "$id" ] || { echo "$host hasn't appeared yet" >&2; exit 1; }
  api "/servers/$id/details" -X PUT -H "Content-Type: application/json" \
    --data "{\"tags\":[\"preview\",\"pr-$pr\"]}" >/dev/null
fi

for _ in $(seq 1 120); do
  s="$(api "/servers/$id")"
  if [ "$(jq -r .state <<<"$s")" = "running" ] && [ "$(jq -r .pendingAction <<<"$s")" = "null" ]; then
    jq -r .ipv4 <<<"$s"
    exit 0
  fi
  sleep 5
done
echo "$host isn't running after 10 minutes" >&2
exit 1

Three details keep it safe to run again:

  • The Idempotency-Key is built from the PR number and the workflow run, so if the order request times out and the step is retried, the API returns the same order instead of placing a second one. A new run gets a new key, but by then the server exists and is found by name. Idempotent orders explains the key's rules.
  • The order's answer doesn't name the server, so the script finds it by hostname, which the PR number makes unique.
  • An order that credit didn't cover stops the job with the invoice address.

Delete the server

scripts/preview-down.sh deletes the PR's server, and does nothing if there isn't one:

bash
#!/usr/bin/env bash
# scripts/preview-down.sh PR_NUMBER: deletes pr-<n>-preview if it exists.
set -euo pipefail
: "${VPS9_TOKEN:?set VPS9_TOKEN}"
API="${VPS9_API_URL:-https://my.vpsnine.com/api/v1}"
host="pr-$1-preview"

api() {
  local path=$1; shift
  curl -sS --fail-with-body -H "Authorization: Bearer $VPS9_TOKEN" "$@" "$API$path"
}

id="$(api /servers | jq -r --arg h "$host" '.data[] | select(.hostname == $h) | .id')"
if [ -z "$id" ]; then
  echo "no $host: nothing to delete"
  exit 0
fi
api "/servers/$id/actions" -X POST -H "Content-Type: application/json" -d '{"action":"delete"}' >/dev/null
echo "deleting $host ($id)"

The workflow

Save this as .github/workflows/preview.yml. It also uses scripts/deploy.sh from the deploy-on-push tutorial, which copies dist/ into a new release and switches /var/www/app/current to it:

yaml
name: Preview server

on:
  pull_request:
    types: [opened, reopened, synchronize, closed]

concurrency:
  group: preview-${{ github.event.pull_request.number }}
  cancel-in-progress: false

permissions:
  contents: read
  pull-requests: write

jobs:
  up:
    if: github.event.action != 'closed' && github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-24.04
    env:
      VPS9_TOKEN: ${{ secrets.VPS9_TOKEN }}
      PR: ${{ github.event.pull_request.number }}
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm

      - name: Build
        run: |
          npm ci
          npm run build

      - name: Order or find the preview server
        id: server
        run: echo "ip=$(scripts/preview-up.sh "$PR")" >> "$GITHUB_OUTPUT"

      - name: Set up SSH
        env:
          IP: ${{ steps.server.outputs.ip }}
        run: |
          install -m 700 -d ~/.ssh
          echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          # A new server: wait for SSH and its first-boot script, then trust its key.
          for _ in $(seq 1 60); do
            ssh-keyscan -t ed25519 "$IP" > ~/.ssh/known_hosts 2>/dev/null && [ -s ~/.ssh/known_hosts ] &&
              ssh "root@$IP" 'test -f /var/lib/vps9-setup.exit' && break
            sleep 5
          done
          ssh "root@$IP" 'exit "$(cat /var/lib/vps9-setup.exit)"'

      - name: Deploy
        run: scripts/deploy.sh "root@${{ steps.server.outputs.ip }}"

      - name: Comment the address on the pull request
        if: github.event.action == 'opened' || github.event.action == 'reopened'
        env:
          GH_TOKEN: ${{ github.token }}
          IP: ${{ steps.server.outputs.ip }}
        run: |
          gh pr comment "$PR" --body "Preview: http://$IP/ (deleted when this pull request closes)"

  down:
    if: github.event.action == 'closed' && github.event.pull_request.head.repo.full_name == github.repository
    runs-on: ubuntu-24.04
    steps:
      - uses: actions/checkout@v4
      - name: Delete the preview server
        env:
          VPS9_TOKEN: ${{ secrets.VPS9_TOKEN }}
        run: scripts/preview-down.sh "${{ github.event.pull_request.number }}"

How it fits together:

  • up runs when a PR opens, gets a new push or reopens. It builds, orders or finds the server, waits until SSH answers and the setup script has finished with exit code 0, deploys, and on the first run comments the address on the PR.
  • down runs when the PR closes, merged or not, and deletes the server.
  • The if on both jobs skips pull requests from forks. Those runs get no secrets, and you don't want strangers' branches ordering servers on your account.
  • concurrency per PR means a quick second push waits for the first run instead of racing it to order.

The workflow trusts the new server's host key the first time it sees it (ssh-keyscan right after the server is built). That's reasonable for a throwaway server you just created; don't copy that shortcut to production, where you record the key once as in the deploy-on-push tutorial.

Check it worked

Open a pull request. Under Actions, the up job should go green in a few minutes, and the PR gets a comment like Preview: http://203.0.113.8/ (deleted when this pull request closes). In the panel the server shows up as pr-<number>-preview with the tags preview and pr-<number>. Or from a terminal:

bash
vps9 --json servers | jq -r '.[] | select(.tags | index("preview")) | [.hostname, .state, .ipv4] | @tsv'

Close the PR, and the down job deletes the server.

A cheaper variant: reuse a pool

Instead of ordering a server per PR, keep two or three servers tagged preview and hand them out:

  1. In preview-up.sh, instead of ordering, pick a preview server that has no pr-* tag, and tag it pr-<number> with PUT /servers/{id}/details.
  2. In preview-down.sh, instead of deleting, set its tags back to ["preview"]. To start the next PR from a clean disk, reinstall it with {"action":"reinstall"}: the reinstall runs the same packages and setup script again, and costs nothing.

You pay for the pool by the month however many PRs pass through it, and a PR waits for the first job if every server is taken.

Troubleshooting

The order wasn't covered by account credit: pay … or top up first. Credit ran out, and the order is waiting as an open invoice. Pay it from the address shown, or top up: new credit pays open invoices first. Once it's paid the server is built, and rerunning the job finds it. Unpaid orders count toward the limit of 3.

The order step fails with exit code 4 and no message. No SSH key called ci-deploy is on the account. Add the workflow's public key under that name.

The setup step loops until it gives up. The setup script failed or never ran. Log in with the workflow's key and read /var/log/vps9-setup.log and /var/log/cloud-init-output.log on the server.

429 rate_limited when many PRs open at once. Orders are limited to 10 an hour per account. Use the pool variant if you regularly open more than that.

Next steps

Be first when orders open

Join the waitlist and we'll send you one email when VPSNine launches. Pick a plan if you already know which one you want, and we'll size the first servers around it.

  • One email, on launch day
  • Monthly or yearly, no lock-in
  • 14-day money-back guarantee