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
- A read-and-write API token for this workflow alone.
- 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 withssh-keygen -t ed25519 -N "" -C ci-deploy -f ci_deploy_key. - In the repository's Settings > Secrets and variables > Actions, two secrets:
VPS9_TOKEN(the token) andDEPLOY_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:
#!/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 nginxWhen 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:
#!/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 1Three details keep it safe to run again:
- The
Idempotency-Keyis 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:
#!/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:
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:
upruns 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 code0, deploys, and on the first run comments the address on the PR.downruns when the PR closes, merged or not, and deletes the server.- The
ifon both jobs skips pull requests from forks. Those runs get no secrets, and you don't want strangers' branches ordering servers on your account. concurrencyper 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:
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:
- In
preview-up.sh, instead of ordering, pick apreviewserver that has nopr-*tag, and tag itpr-<number>withPUT /servers/{id}/details. - 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, for production.
- Extra packages and setup scripts, for what else a first-boot script can do.
- Account credit and invoices, to keep the workflow paid for.