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

Source: https://vpsnine.com/tutorials/deploy-on-push-github-actions-vpsnine-api · Updated: 2026-10-21

Difficulty: intermediate · Time: about 40 minutes · Tested on: Ubuntu 24.04 LTS

Prerequisites:

- 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](https://vpsnine.com/help/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](https://vpsnine.com/help/api-tokens-for-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](https://vpsnine.com/help/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

- [Create and destroy preview servers per pull request](https://vpsnine.com/tutorials/preview-servers-per-pull-request), the same pipeline for branches.
- [Use curl and jq with the VPSNine API](https://vpsnine.com/tutorials/use-curl-and-jq-with-the-vpsnine-api) to add your own API steps.
- [Snapshots](https://vpsnine.com/help/snapshots), for how snapshots and their limits work.
