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:

bash
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 rsync

Point nginx at the current symlink. Write /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;
    }
}

Enable it in place of the default site and reload:

bash
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 nginx

For 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:

bash
ssh-keygen -t ed25519 -N "" -C "github-actions deploy" -f deploy_key

Put the public half on the server:

bash
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_keys

Record the server's host key from your computer, so the workflow can check it's talking to your server and not something in between:

bash
ssh-keyscan -t ed25519 www.example.com

Check 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:

bash
#!/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:

bash
VPS9_TOKEN="$(cat ~/.vps9-token)" scripts/snapshot.sh web-1
text
snapshot deploy-manual (snap_wkmq3rz53cmsfbaj) is ready

The 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:

bash
#!/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:

yaml
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/null

What each part does:

  • concurrency lets one deploy finish before the next starts, so two quick pushes never interleave.
  • environment: production lets 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_hosts means 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:

bash
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:

bash
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

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