Deploying a Blog with GitHub Actions and a VPS

Published: 9/4/2026

CI/CDGitHub ActionsVPS

Publishing a small site can begin with a few manual commands: SSH into the server, pull the latest commit, build it, and restart the process. That works until a command is forgotten, the server builds differently from the laptop, or a failed update leaves production between two versions.

For zuphertron.cc, I wanted publishing to mean one thing: push a reviewed commit to main. The deployment system then has to guarantee a few properties:

Those requirements lead to a versioned design. Instead of copying new files over the running application, every commit gets its own release directory. A current symlink identifies the live version. Deployment prepares a complete candidate first, switches the symlink, and keeps the previous target available for rollback.

That extra structure solves a specific problem: an in-place update has no clean boundary between “old” and “new.” A versioned release gives us a candidate we can validate, an atomic point where it becomes live, and a previous version we can restore.

Rough plan

With those goals established, the broad design is:

  1. Prepare isolated identities and services on the VPS.
  2. Give the deployment identity one narrowly privileged operation.
  3. Give GitHub a dedicated, verified SSH connection.
  4. Check, build, and package a release in GitHub Actions.
  5. Push the release to the VPS and invoke its deployment program.
  6. Activate it, check its health, and roll back on failure.
  7. Enable automatic deployment only after the entire path is ready.

The examples use deliberately generic names such as example-blog, example-deploy, and owner/example-blog. Replace them with your own values.

Engineering hint: why does GitHub push instead of the VPS pulling?

GitHub already has the commit, an isolated runner, and a clear event that starts the deployment. Letting that runner build once and push one artifact means the VPS does not need a repository checkout, GitHub account credentials, or development dependencies.

A VPS-pull design can also work, especially with a self-hosted runner or a private network. I chose push because it keeps the production server passive and makes the exact thing tested by CI the thing that gets deployed.

1. Prepare the VPS

Goal: We want the VPS to run the blog from a predictable location and restart it when necessary.

Blocker: Using a personal administrator account would be convenient, but it would give the application and deployment process access to unrelated files and administrative commands. A fresh server also has no standard place for releases, production configuration, or service management.

Plan: Apply the principle of least privilege by separating three roles:

Production secrets will live in a root-owned environment file readable by the application group, while systemd will provide consistent commands for migrations and application startup.

The separation limits what each process can reach if it is compromised. Create the two service identities and their release directories:

sudo adduser --system --group 
  --home /srv/example-blog 
  --no-create-home 
  --shell /usr/sbin/nologin 
  example-blog

sudo adduser --disabled-password --gecos '' example-deploy
sudo install -d -o example-blog -g example-blog /srv/example-blog
sudo install -d -o example-blog -g example-blog /srv/example-blog/releases
Engineering hint: why use two unprivileged accounts?

The application account reads production configuration and runs the website. The deployment account accepts SSH but does not need to read the application’s secrets. Separating them means a deployment login does not automatically become an application login, and the application process cannot rewrite the deployment key.

Keep runtime secrets on the VPS, outside every release:

sudoedit /etc/example-blog.env
sudo chown root:example-blog /etc/example-blog.env
sudo chmod 640 /etc/example-blog.env

For a Node application, a minimal systemd unit could look like this:

# /etc/systemd/system/example-blog.service
[Unit]
Description=Example blog
After=network-online.target
Wants=network-online.target

[Service]
Type=simple
User=example-blog
WorkingDirectory=/srv/example-blog/current
EnvironmentFile=/etc/example-blog.env
Environment=NODE_ENV=production
Environment=HOST=127.0.0.1
Environment=PORT=3000
ExecStart=/usr/bin/node build/index.js
Restart=on-failure
RestartSec=5

[Install]
WantedBy=multi-user.target

Run database migrations through a separate one-shot unit so they get the same user, working directory, and environment file every time:

# /etc/systemd/system/example-blog-migrate.service
[Unit]
Description=Migrate example blog database
After=postgresql.service

[Service]
Type=oneshot
User=example-blog
WorkingDirectory=/srv/example-blog/current
EnvironmentFile=/etc/example-blog.env
ExecStart=/usr/bin/node scripts/migrate.mjs

Load it now, but wait until a release exists before starting it:

sudo systemctl daemon-reload
sudo systemctl enable example-blog.service

Your reverse proxy can continue serving HTTPS publicly while forwarding requests to 127.0.0.1:3000.

2. Limit what a deployment can do

Goal: We want GitHub to upload a release and activate it on the VPS.

Blocker: The deployment account is intentionally unprivileged, but activation must update a system-owned symlink and restart system services. Giving the account unrestricted sudo would solve the immediate permission error by creating a much larger security problem.

Plan: Keep privileged behavior inside one root-owned program with fixed responsibilities, then authorize the deployment account to run only that program. Compared with broad sudo access, this gives us one small interface to review and validate.

Because the program runs as root, every argument and uploaded file must be treated as untrusted. Before writing it, define its rules:

Sign in to the VPS with the personal administrator account and create a working file using your preferred editor. For example:

vim ~/deploy-example-blog

Use the following content:

#!/usr/bin/env bash
set -Eeuo pipefail

# Fixed server-side settings. These are not supplied by GitHub.
readonly app_root=/srv/example-blog
readonly app_user=example-blog
readonly npm_bin=/usr/bin/npm
readonly app_service=example-blog.service
readonly migration_service=example-blog-migrate.service
readonly health_url=http://127.0.0.1:3000/api/health

# Stop on an invalid release or failed deployment operation.
fail() {
  echo "Deployment failed: $*" >&2
  exit 1
}

[[ $EUID -eq 0 ]] || fail 'this command must run as root'

# Read the release identity and uploaded paths from the SSH command.
readonly release_id=${1:-}
readonly archive_path=${2:-}
readonly checksum_path=${3:-}

[[ $release_id =~ ^[0-9a-f]{40}$ ]] || 
  fail 'release id must be a full Git commit SHA'

# Accept only the exact filenames expected for that commit.
readonly expected_archive="/tmp/example-blog-$release_id.tar.gz"
readonly expected_checksum="$expected_archive.sha256"

[[ $archive_path == "$expected_archive" ]] || 
  fail 'unexpected release archive path'
[[ $checksum_path == "$expected_checksum" ]] || 
  fail 'unexpected checksum path'
[[ -f $expected_archive && -f $expected_checksum ]] || 
  fail 'release upload is incomplete'

# Remove temporary uploads whether deployment succeeds or fails.
cleanup_upload() {
  rm -f -- "$expected_archive" "$expected_checksum"
}
trap cleanup_upload EXIT

# Verify the upload and reject archive paths that could escape the release directory.
cd /tmp
sha256sum --check "$(basename "$expected_checksum")"

while IFS= read -r entry; do
  case "$entry" in
    /* | ../* | */../*) fail "unsafe path in release archive: $entry" ;;
  esac
done < <(tar -tzf "$expected_archive")

# Create one immutable directory for this commit and extract as the app user.
readonly releases_directory="$app_root/releases"
readonly release_directory="$releases_directory/$release_id"
readonly npm_cache="$app_root/.npm-cache"
readonly app_group=$(id -gn "$app_user")

install -d -o "$app_user" -g "$app_group" 
  "$app_root" "$releases_directory" "$npm_cache"
install -d -o "$app_user" -g "$app_group" "$release_directory"

runuser -u "$app_user" -- 
  tar -xzf "$expected_archive" -C "$release_directory"

# Check the artifact shape, then install production dependencies without root.
[[ -f $release_directory/build/index.js ]] || 
  fail 'release does not contain build/index.js'
[[ -f $release_directory/package-lock.json ]] || 
  fail 'release does not contain package-lock.json'
[[ -f $release_directory/scripts/migrate.mjs ]] || 
  fail 'release does not contain the migration runner'

runuser -u "$app_user" -- env npm_config_cache="$npm_cache" 
  "$npm_bin" ci 
  --omit=dev 
  --ignore-scripts 
  --prefix "$release_directory"

# Remember the live release so a failed deployment can restore it.
previous_release=''
if [[ -L $app_root/current ]]; then
  previous_release=$(readlink -f "$app_root/current")
fi

# Switch the current symlink atomically and define the rollback path.
activate_release() {
  local target=$1
  ln -sfn "$target" "$app_root/current.next"
  mv -Tf "$app_root/current.next" "$app_root/current"
}

rollback() {
  if [[ -n $previous_release && -d $previous_release ]]; then
    echo "Rolling back to $previous_release..." >&2
    activate_release "$previous_release"
    systemctl restart "$app_service"
  fi
}

# Activate the candidate, migrate, and restart the application.
activate_release "$release_directory"

if ! systemctl start "$migration_service"; then
  rollback
  fail 'database migration failed'
fi

if ! systemctl restart "$app_service"; then
  rollback
  fail 'application service failed to restart'
fi

# Give the application 30 seconds to become healthy before rolling back.
healthy=false
for _ in {1..15}; do
  if curl --fail --silent --show-error "$health_url" >/dev/null; then
    healthy=true
    break
  fi
  sleep 2
done

if [[ $healthy != true ]]; then
  rollback
  fail 'health check did not pass within 30 seconds'
fi

echo "Deployed release $release_id."

Save the file, then restrict the working copy to the administrator while it is being reviewed:

chmod 700 ~/deploy-example-blog

Review it especially carefully because the installed program will be allowed to run as root. Once it matches the plan above, install it into the system command directory:

sudo install -o root -g root -m 755 
  ~/deploy-example-blog 
  /usr/local/sbin/deploy-example-blog

install creates or replaces /usr/local/sbin/deploy-example-blog with the reviewed file, owned by root:root and executable by all users.

With the executable installed, create the narrow permission rule:

sudoedit /etc/sudoers.d/example-blog-deploy

Put this one rule in the file:

# /etc/sudoers.d/example-blog-deploy
example-deploy ALL=(root) NOPASSWD: /usr/local/sbin/deploy-example-blog

Validate the rule before depending on it:

sudo chmod 440 /etc/sudoers.d/example-blog-deploy
sudo visudo -cf /etc/sudoers.d/example-blog-deploy

This matters because a narrow sudo rule is only as safe as the program behind it.

3. Set up the SSH credentials

Goal: We want the GitHub Actions runner to authenticate to the VPS without a password.

Blocker: SSH must answer two different questions: “Is this really our workflow?” and “Is this really our VPS?” Reusing a personal key couples personal and automated access, while skipping server verification permits a man-in-the-middle attack.

Plan: Create a key used only by this deployment, give its public half to the deployment account, store its private half in GitHub Actions secrets, and pin the VPS host key after verifying it through a separate trusted channel.

1. Generate the deployment key on your own computer.

Do not run this on the VPS, and do not reuse your personal SSH key. The empty passphrase allows the non-interactive workflow to use this dedicated key; access is constrained by the deployment account and its narrow sudo rule.

ssh-keygen -t ed25519 
  -C "github-actions-example-blog" 
  -f ~/.ssh/example-blog-actions 
  -N ''

This creates two files:

Display the public key and copy its single line:

cat ~/.ssh/example-blog-actions.pub

2. Add that public line to the deployment account on the VPS.

Open a separate terminal and sign in to the VPS with the personal administrator account. Prepare the deployment account’s SSH directory and key file:

sudo install -d -m 700 -o example-deploy -g example-deploy 
  /home/example-deploy/.ssh
sudo -u example-deploy touch /home/example-deploy/.ssh/authorized_keys
sudo chmod 600 /home/example-deploy/.ssh/authorized_keys

Now open that file:

sudoedit /home/example-deploy/.ssh/authorized_keys

Paste the one public-key line copied from your own computer, save, and close the editor. Confirm the final ownership and permissions:

sudo chown example-deploy:example-deploy 
  /home/example-deploy/.ssh/authorized_keys
sudo chmod 600 /home/example-deploy/.ssh/authorized_keys

3. Test the dedicated login from your own computer.

This test proves that the private key matches the public key installed on the VPS:

ssh 
  -i ~/.ssh/example-blog-actions 
  -o IdentitiesOnly=yes 
  example-deploy@zuphertron.cc

Exit that session after it connects. Fix key or permission problems here, before involving GitHub Actions.

4. Record and independently verify the VPS host key.

On your own computer, collect only the server’s Ed25519 host key and print its fingerprint:

ssh-keyscan -t ed25519 -p 22 zuphertron.cc 
  > ~/example-blog-known-hosts
ssh-keygen -lf ~/example-blog-known-hosts

Through the VPS provider’s trusted console, print the fingerprint directly from the server:

sudo ssh-keygen -lf /etc/ssh/ssh_host_ed25519_key.pub

The SHA256:... fingerprint must match in both places. Do not run ssh-keyscan inside the workflow and blindly trust its result; that would collect and trust the key over the same connection we are trying to verify.

5. Add the verified values to GitHub.

In the repository, open Settings → Secrets and variables → Actions. Create DEPLOY_SSH_KEY from the entire private-key file, including its BEGIN and END lines:

cat ~/.ssh/example-blog-actions

Create DEPLOY_KNOWN_HOSTS from the verified host-key line:

cat ~/example-blog-known-hosts

Then add the remaining variables shown below. Keep deployment disabled until the VPS setup is complete:

TypeNameValue
SecretDEPLOY_SSH_KEYContents of the private key
SecretDEPLOY_KNOWN_HOSTSThe verified SSH host-key line
VariableDEPLOY_HOSTzuphertron.cc
VariableDEPLOY_USERexample-deploy
VariableDEPLOY_PORTThe SSH port, usually 22
VariableDEPLOY_ENABLEDLeave unset until setup is complete

4. Check, build, and package in GitHub Actions

Goal: We want every push to main to produce a checked, runnable release.

Blocker: Each Actions runner starts empty, application code can fail its checks or build, and rebuilding later on the VPS could produce something different from what CI verified. An interrupted transfer could also leave a partial archive that still has the expected filename.

Plan: Install from the lockfile, run checks, build once inside CI, package only the files required at runtime, and calculate a checksum. The VPS will deploy that exact artifact only after verifying the checksum.

Create .github/workflows/deploy.yml:

name: Verify and deploy

on:
  pull_request:
  push:
    branches: [main]
  workflow_dispatch:

permissions:
  contents: read

concurrency:
  group: example-blog-${{ github.ref }}
  cancel-in-progress: false

jobs:
  verify-and-deploy:
    runs-on: ubuntu-latest
    timeout-minutes: 15

    steps:
      - name: Check out repository
        uses: actions/checkout@v6

      - name: Set up Node.js
        uses: actions/setup-node@v6
        with:
          node-version: 22
          cache: npm

      - name: Install dependencies
        run: npm ci

      - name: Check application
        run: npm run check

      - name: Build production release
        run: npm run build

      - name: Package release
        if: vars.DEPLOY_ENABLED == 'true' && github.ref == 'refs/heads/main'
        env:
          RELEASE_ARCHIVE: example-blog-${{ github.sha }}.tar.gz
        run: |
          tar -czf "$RUNNER_TEMP/$RELEASE_ARCHIVE"             build             migrations             scripts/migrate.mjs             package.json             package-lock.json
          cd "$RUNNER_TEMP"
          sha256sum "$RELEASE_ARCHIVE" > "$RELEASE_ARCHIVE.sha256"

Pull requests stop after verification because their ref is not main. Pushes to main may continue into deployment once DEPLOY_ENABLED is set to true.

Engineering hint: why package an artifact instead of running git pull or rsync?

An archive is immutable and has a clear identity: the commit SHA. The workflow checks and builds it once, calculates its checksum, and sends those exact bytes to production. Versioned archives also make rollback straightforward because previous releases can stay on disk untouched.

With git pull, the VPS becomes another build environment and needs repository credentials. With an in-place rsync, removed or half-copied files can leave production between versions unless extra care is taken.

5. Upload the release

Goal: We want GitHub to push the packaged files onto the VPS and ask the VPS to activate them.

Blocker: The workflow must use secrets without writing them into the repository, and SSH must refuse an unrecognized server.

Plan: Materialize the SSH key and verified host entry only inside the temporary runner, upload the archive and checksum with scp, then use the same connection settings to invoke the one permitted deployment command over ssh.

Both deployment steps belong in the existing .github/workflows/deploy.yml, inside the verify-and-deploy job’s steps: list, immediately after Package release. The surrounding workflow structure looks like this:

jobs:
  verify-and-deploy:
    # runs-on and other job settings from step 4
    steps:
      # checkout, install, check, build, and Package release from step 4

      - name: Configure deployment SSH key
        if: vars.DEPLOY_ENABLED == 'true' && github.ref == 'refs/heads/main'
        env:
          DEPLOY_KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
          DEPLOY_SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
        run: |
          test -n "$DEPLOY_KNOWN_HOSTS"
          test -n "$DEPLOY_SSH_KEY"
          install -m 700 -d "$RUNNER_TEMP/ssh"
          printf '%s\n' "$DEPLOY_KNOWN_HOSTS" > "$RUNNER_TEMP/ssh/known_hosts"
          printf '%s\n' "$DEPLOY_SSH_KEY" > "$RUNNER_TEMP/ssh/deploy_key"
          chmod 600 "$RUNNER_TEMP/ssh/known_hosts" "$RUNNER_TEMP/ssh/deploy_key"

      - name: Upload and activate release
        if: vars.DEPLOY_ENABLED == 'true' && github.ref == 'refs/heads/main'
        env:
          RELEASE_ARCHIVE: example-blog-${{ github.sha }}.tar.gz
          DEPLOY_HOST: ${{ vars.DEPLOY_HOST }}
          DEPLOY_PORT: ${{ vars.DEPLOY_PORT || '22' }}
          DEPLOY_USER: ${{ vars.DEPLOY_USER }}
        run: |
          test -n "$DEPLOY_HOST"
          test -n "$DEPLOY_USER"
          scp             -i "$RUNNER_TEMP/ssh/deploy_key"             -o UserKnownHostsFile="$RUNNER_TEMP/ssh/known_hosts"             -P "$DEPLOY_PORT"             "$RUNNER_TEMP/$RELEASE_ARCHIVE"             "$RUNNER_TEMP/$RELEASE_ARCHIVE.sha256"             "$DEPLOY_USER@$DEPLOY_HOST:/tmp/"
          ssh             -i "$RUNNER_TEMP/ssh/deploy_key"             -o UserKnownHostsFile="$RUNNER_TEMP/ssh/known_hosts"             -p "$DEPLOY_PORT"             "$DEPLOY_USER@$DEPLOY_HOST"             "sudo /usr/local/sbin/deploy-example-blog               '${{ github.sha }}'               '/tmp/$RELEASE_ARCHIVE'               '/tmp/$RELEASE_ARCHIVE.sha256'"

The public repository now contains only variable and secret names. The private key and known-hosts entry live in GitHub’s encrypted Actions secrets, while database, email, and API credentials remain only in /etc/example-blog.env on the VPS.

6. Activate, migrate, health-check, and roll back

Goal: We want the uploaded release to become live only through a predictable sequence.

Blocker: Installation, database migration, process restart, or application startup can fail after the upload succeeds.

Plan: Treat the upload as a candidate rather than the live application. Validate and install it in its own directory, remember the previous release, switch current atomically, migrate and restart, then accept the candidate only after its health check passes. Any failure after the switch restores the previous target.

Activation is handled by the root-owned /usr/local/sbin/deploy-example-blog program installed in step 2. The smaller snippets below isolate parts of that file for explanation.

If the program itself changes, prepare and review a new working copy in the administrator’s home directory, then repeat the sudo install command from step 2. A normal application deployment deliberately does not overwrite this privileged executable.

The Upload and activate release workflow step first copies the archive and checksum into /tmp, then its final ssh command runs:

sudo /usr/local/sbin/deploy-example-blog 
  '<commit-sha>' 
  '/tmp/example-blog-<commit-sha>.tar.gz' 
  '/tmp/example-blog-<commit-sha>.tar.gz.sha256'

The restricted sudoers rule from step 2 lets example-deploy run that program without an interactive password. The complete trigger chain is therefore:

push to main
    ↓
GitHub Actions checks, builds, and packages
    ↓
scp uploads the archive and checksum
    ↓
ssh invokes the root-owned deployment program
    ↓
the deployment program performs the sequence below

The root-owned deployment program follows this order:

validate commit SHA and paths
        ↓
verify checksum and archive entries
        ↓
extract to /srv/example-blog/releases/<commit-sha>
        ↓
npm ci --omit=dev --ignore-scripts
        ↓
remember the current release
        ↓
atomically point current at the new release
        ↓
run database migrations
        ↓
restart the application
        ↓
poll http://127.0.0.1:3000/api/health
        ↓
success, or restore the previous current target and restart
        ↓
optionally remove releases older than the retention limit

The activation and rollback portion of the complete file from step 2 is:

previous_release="$(readlink -f /srv/example-blog/current || true)"
new_release="/srv/example-blog/releases/$release_id"

activate_release() {
  local target=$1
  ln -sfn "$target" /srv/example-blog/current.next
  mv -Tf /srv/example-blog/current.next /srv/example-blog/current
}

rollback() {
  if [[ -n "$previous_release" && -d "$previous_release" ]]; then
    activate_release "$previous_release"
    systemctl restart example-blog.service
  fi
}

activate_release "$new_release"
systemctl start example-blog-migrate.service || { rollback; exit 1; }
systemctl restart example-blog.service || { rollback; exit 1; }

healthy=false
for _ in {1..15}; do
  if curl --fail --silent http://127.0.0.1:3000/api/health >/dev/null; then
    healthy=true
    break
  fi
  sleep 2
done

if [[ $healthy != true ]]; then
  rollback
  exit 1
fi

This excerpt focuses on activation and rollback; the complete file in step 2 also contains the input, checksum, and archive checks.

Problem: Each release contains another production install, including node_modules. If nothing removes old releases, disk usage keeps growing until the VPS eventually runs out of space.

The base deployment program retains release directories after the health check. For long-term use, add a data-retention policy. Keep several recent releases—not just one—so a bad release still has a rollback target. For example, this removes releases beyond the five newest while explicitly protecting the current and previous releases:

# Run only after the new release passes its health check.
keep_releases=5
releases_directory=/srv/example-blog/releases
current_release="$(readlink -f /srv/example-blog/current)"

mapfile -t releases < <(
  find "$releases_directory" -mindepth 1 -maxdepth 1 -type d 
    -printf '%T@ %p\n' |
    sort -nr |
    cut -d' ' -f2-
)

for ((index = keep_releases; index < ${#releases[@]}; index++)); do
  release=${releases[$index]}

  if [[ $release == "$current_release" || $release == "$previous_release" ]]; then
    continue
  fi

  rm -rf -- "$release"
done

Run cleanup only after the health check succeeds. Cleaning earlier could delete the version needed by rollback. The upload archive and checksum in /tmp are a separate concern; the deployment program should remove those on every exit with an EXIT trap.

Engineering hint: why use a current symlink and versioned directories?

Changing a symlink with a rename gives us an atomic switch: other processes see either the old release or the new one, rather than a directory while files are being replaced. Keeping the previous target makes application rollback fast and does not require another network transfer.

Engineering hint: what makes database rollback different?

Switching application files back does not undo a database migration. New migrations therefore need to remain compatible with the previous application release. A common pattern is expand-and-contract: first add the new schema in a compatible form, deploy code that can use it, and remove old schema only in a later release.

7. Enable the first deployment

Goal: We want the next push to main to use the completed path from GitHub to the VPS.

Blocker: Enabling deployment before the users, key, directories, service, environment file, and root-owned deployment program exist will turn an ordinary push into a failed production job.

Plan: Keep deployment behind an explicit disabled-by-default variable, establish an initial release that can serve as a rollback target, test the restricted connection, and enable automation only after every dependency is in place.

First deploy a bootstrap release or create the initial current target manually. Test the restricted login and command from a trusted machine. Then set the GitHub Actions variable:

DEPLOY_ENABLED=true

Publish normally:

npm run check
npm run build
git add .
git commit -m "Publish new post"
git push origin main

Watch the Actions job for the first deployment, then verify both the internal health endpoint on the VPS and the public site:

curl --fail http://127.0.0.1:3000/api/health
curl --fail https://zuphertron.cc/api/health

After that, the routine is simple: push to main; GitHub checks and builds; Actions uploads one identified release; the VPS activates it; and the health check either accepts it or restores the previous version.