Deploy on push with GitHub Actions and the VPSNine API
Build on every push to main in GitHub Actions, snapshot the VPSNine server through the API, then ship with rsync over SSH and switch releases atomically.
Tested on Ubuntu 24.04 LTS
Before you start
- A VPSNine server running Ubuntu 24.04 with nginx
- A GitHub repository whose build writes static files to dist/
- A read-and-write API token if you want the snapshot step
To deploy on every push with GitHub Actions, build the site in the workflow, copy the result to your server with rsync over SSH as a dedicated deploy user, and switch a current symlink to the new release so visitors never see a half-copied site. This tutorial also takes a VPSNine snapshot through the API before each deploy, so a bad release is one revert away.
What we tested: the workflow passes actionlint; the snapshot script ran against a local copy of the VPSNine API, including the case where the server already had three snapshots; and the deploy script shipped a build to an Ubuntu 24.04 server running nginx and systemd, where curl then served the new page and old releases were pruned. GitHub's own runners weren't part of the test.
The examples use these names. Replace them with yours:
| What | In this tutorial |
|---|---|
| The server's hostname in VPSNine | web-1 |
| The site's domain | www.example.com |
| The deploy user on the server | deploy |
| Where releases live | /var/www/app/releases/<time>-<commit> |
| What nginx serves | /var/www/app/current, a symlink |
Prepare the server
On the server, as root or with sudo. Create the deploy user, which owns the site and nothing else:
sudo adduser --disabled-password --gecos "" deploy
sudo install -d -o deploy -g deploy /var/www/app /var/www/app/releases
sudo apt install -y nginx rsyncPoint nginx at the current symlink. Write /etc/nginx/sites-available/app:
server {
listen 80 default_server;
listen [::]:80 default_server;
root /var/www/app/current;
index index.html;
location / {
try_files $uri $uri/ =404;
}
}Enable it in place of the default site and reload:
sudo ln -sf /etc/nginx/sites-available/app /etc/nginx/sites-enabled/app
sudo rm -f /etc/nginx/sites-enabled/default
sudo nginx -t && sudo systemctl reload nginxFor HTTPS on your domain, follow nginx and HTTPS once this works over HTTP; the deploy steps don't change.
Make a deploy key
On your own computer, make a key pair just for GitHub to log in as deploy. No passphrase, because a workflow can't type one:
ssh-keygen -t ed25519 -N "" -C "github-actions deploy" -f deploy_keyPut the public half on the server:
sudo install -d -m 700 -o deploy -g deploy /home/deploy/.ssh
echo "ssh-ed25519 AAAA… github-actions deploy" | sudo tee -a /home/deploy/.ssh/authorized_keys
sudo chown deploy:deploy /home/deploy/.ssh/authorized_keys
sudo chmod 600 /home/deploy/.ssh/authorized_keysRecord the server's host key from your computer, so the workflow can check it's talking to your server and not something in between:
ssh-keyscan -t ed25519 www.example.comCheck it worked: ssh -i deploy_key deploy@www.example.com true should log in and return without a password prompt.
Add the secrets and variables to GitHub
In the repository, open Settings > Secrets and variables > Actions.
Under Secrets:
| Name | Value |
|---|---|
DEPLOY_SSH_KEY |
The whole content of deploy_key (the private half) |
DEPLOY_KNOWN_HOSTS |
The line ssh-keyscan printed |
VPS9_TOKEN |
A read-and-write API token, for the snapshot step |
Under Variables:
| Name | Value |
|---|---|
DEPLOY_HOST |
www.example.com |
DEPLOY_SERVER |
web-1, the server's hostname in VPSNine |
SNAPSHOT_BEFORE_DEPLOY |
true, or leave it out to skip snapshots |
Make the token for this job alone, named after it, with a 90-day expiry. Don't limit it to addresses: GitHub's runners don't have fixed ones. API tokens for CI and automation explains why and how to rotate it. Then delete deploy_key from your computer, or keep it in a password manager.
The snapshot script
Save this as scripts/snapshot.sh in the repository and make it executable (chmod +x scripts/*.sh). It finds the server by hostname, makes room if the server already has its three snapshots (deleting only the oldest one it took itself), takes a snapshot named after the commit, and waits until it's ready:
#!/usr/bin/env bash
# scripts/snapshot.sh HOSTNAME: takes a "deploy-…" snapshot of the server and waits until it is ready.
# Makes room first by deleting the oldest "deploy-" snapshot when the server already has 3 of its own.
set -euo pipefail
: "${VPS9_TOKEN:?set VPS9_TOKEN}"
API="${VPS9_API_URL:-https://my.vpsnine.com/api/v1}"
host="$1"
api() {
local path=$1; shift
curl -sS --fail-with-body --retry 3 -H "Authorization: Bearer $VPS9_TOKEN" "$@" "$API$path"
}
wait_idle() {
for _ in $(seq 1 60); do
[ "$(api "/servers/$id" | jq -r .pendingAction)" = "null" ] && return 0
sleep 5
done
echo "$host is still busy after 5 minutes" >&2
return 1
}
id="$(api /servers | jq -r --arg h "$host" '.data[] | select(.hostname == $h) | .id')"
[ -n "$id" ] || { echo "no server called $host" >&2; exit 1; }
[ "$(wc -l <<<"$id")" -eq 1 ] || { echo "more than one server is called $host" >&2; exit 1; }
snaps="$(api "/servers/$id/snapshots")"
if [ "$(jq '[.data[] | select(.automatic | not)] | length' <<<"$snaps")" -ge 3 ]; then
old="$(jq -r '[.data[] | select((.automatic | not) and (.label | startswith("deploy-")))][0].id // empty' <<<"$snaps")"
if [ -z "$old" ]; then
echo "$host already has 3 snapshots that this script didn't take; delete one in the panel" >&2
exit 1
fi
api "/servers/$id/snapshots/$old" -X DELETE >/dev/null
wait_idle
fi
label="deploy-${GITHUB_SHA:-manual}"
snap="$(jq -n --arg n "${label:0:40}" '{name: $n}' |
api "/servers/$id/snapshots" -X POST -H "Content-Type: application/json" --data @- | jq -r .snapshot.id)"
wait_idle
state="$(api "/servers/$id/snapshots" | jq -r --arg s "$snap" '.data[] | select(.id == $s) | .state')"
[ "$state" = "ready" ] || { echo "snapshot $snap ended as ${state:-missing}" >&2; exit 1; }
echo "snapshot ${label:0:40} ($snap) is ready"A running server shuts down for a snapshot and starts again, usually within a minute, so the site is briefly offline. If that's too much on every push, set SNAPSHOT_BEFORE_DEPLOY only when you expect a risky release, or rely on automatic snapshots at night instead.
Try it from your computer before wiring it in:
VPS9_TOKEN="$(cat ~/.vps9-token)" scripts/snapshot.sh web-1snapshot deploy-manual (snap_wkmq3rz53cmsfbaj) is readyThe deploy script
Save this as scripts/deploy.sh. It copies dist/ into a new release folder, then switches current to it with a rename, which is atomic: nginx serves either the old release or the new one, never a mix. It keeps the five newest releases:
#!/usr/bin/env bash
# scripts/deploy.sh USER@HOST: copies dist/ to a new release folder, switches the "current" link, keeps 5 releases.
# Release names start with the UTC time, so sorting them by name sorts them by age.
set -euo pipefail
target="$1"
release="$(date -u +%Y%m%d%H%M%S)-${GITHUB_SHA:-local}"
base=/var/www/app
ssh "$target" "mkdir -p $base/releases/$release"
rsync -az --delete dist/ "$target:$base/releases/$release/"
ssh "$target" "ln -sfn $base/releases/$release $base/current.tmp && mv -T $base/current.tmp $base/current &&
cd $base/releases && ls -1 | sort -r | tail -n +6 | xargs -r rm -rf"
echo "deployed $release to $target"The workflow
Save this as .github/workflows/deploy.yml:
name: Deploy
on:
push:
branches: [main]
workflow_dispatch:
concurrency:
group: deploy-production
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-24.04
environment: production
env:
DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
DEPLOY_SERVER: ${{ vars.DEPLOY_SERVER }}
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: Set up SSH
run: |
install -m 700 -d ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Snapshot the server first
if: vars.SNAPSHOT_BEFORE_DEPLOY == 'true'
env:
VPS9_TOKEN: ${{ secrets.VPS9_TOKEN }}
run: scripts/snapshot.sh "$DEPLOY_SERVER"
- name: Deploy
run: scripts/deploy.sh "deploy@$DEPLOY_HOST"
- name: Check the site answers
run: curl -fsS --retry 5 --retry-all-errors "https://$DEPLOY_HOST/" -o /dev/nullWhat each part does:
concurrencylets one deploy finish before the next starts, so two quick pushes never interleave.environment: productionlets you add a required reviewer or a branch rule under Settings > Environments later.- The secrets go only to the steps that need them, and
known_hostsmeans SSH refuses a server that isn't yours. - The last step fails the run if the site doesn't answer after the deploy.
If your project isn't Node.js, replace the two Node steps and npm commands with your own build; the deploy only needs a dist/ folder.
Check it worked
Push to main and open the run under Actions. Each step should be green; the snapshot step prints snapshot deploy-<commit> … is ready. Then:
curl -sI https://www.example.com/ | head -1
ssh deploy@www.example.com 'readlink /var/www/app/current; ls /var/www/app/releases'current points at the newest release, and there are at most five. We checked this with seven deploys in a row: the two oldest were removed, and the rollback below stepped back through them in order.
Roll back
To go back one release, point current at the release before the one it points at now. Run it again to go back another:
ssh deploy@www.example.com 'cd /var/www/app/releases && cur=$(basename "$(readlink ../current)") &&
prev=$(ls -1 | sort -r | grep -A1 -x "$cur" | sed -n 2p) && [ -n "$prev" ] &&
ln -sfn "/var/www/app/releases/$prev" ../current.tmp && mv -T ../current.tmp ../current && echo "now serving $prev"'Releases are sorted by name, not by modification time: rsync -a copies your build folder's own timestamp onto each release, so ls -t can put them in the wrong order. The next push deploys a new release as usual.
If the release also changed something outside the site, like a database migration, revert the whole server to the snapshot the workflow took: vps9 snapshots web-1 revert deploy-<commit>, or revert on the server's Snapshots tab. Everything written to the server since the snapshot is lost, so do that only when the release itself is the problem.
Troubleshooting
Host key verification failed. DEPLOY_KNOWN_HOSTS doesn't match the server, usually after a reinstall. Run ssh-keyscan again and update the secret.
Permission denied (publickey). The private key in DEPLOY_SSH_KEY doesn't match a line in /home/deploy/.ssh/authorized_keys, or that file isn't owned by deploy with mode 600.
web-1 already has 3 snapshots that this script didn't take; delete one in the panel. The server's three manual snapshots are all ones you took by hand. Delete one, and the script keeps rotating its own from then on.
The snapshot step fails with 401 unauthorized. The VPS9_TOKEN secret is missing, revoked or expired, or the workflow ran from a fork, which gets no secrets.
Next steps
- Create and destroy preview servers per pull request, the same pipeline for branches.
- Use curl and jq with the VPSNine API to add your own API steps.
- Snapshots, for how snapshots and their limits work.