# 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.

Source: https://vpsnine.com/tutorials/preview-servers-per-pull-request · Updated: 2026-10-21

Difficulty: advanced · Time: about 45 minutes · Tested on: Ubuntu 24.04 LTS

Prerequisites:

- 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](#a-cheaper-variant-reuse-a-pool) 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](https://vpsnine.com/help/account-credit-and-invoices)). 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](https://vpsnine.com/help/api-tokens-for-automation) 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](https://vpsnine.com/help/setup-scripts), 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](https://vpsnine.com/help/idempotent-orders-with-idempotency-key) 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](https://vpsnine.com/tutorials/deploy-on-push-github-actions-vpsnine-api), 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

- [Deploy on push with GitHub Actions and the VPSNine API](https://vpsnine.com/tutorials/deploy-on-push-github-actions-vpsnine-api), for production.
- [Extra packages and setup scripts](https://vpsnine.com/help/setup-scripts), for what else a first-boot script can do.
- [Account credit and invoices](https://vpsnine.com/help/account-credit-and-invoices), to keep the workflow paid for.
