DeployEasy
CI/CDIntermediate

GitHub Actions CI/CD: Automatically Deploy Node.js to a VPS

Create a secure GitHub Actions pipeline that builds, tests, and deploys Node.js to a VPS over SSH with secrets, health checks, concurrency, and rollback.

· 3 min read· 574 words
Table of contents

Manual SSH deployments are easy to start and difficult to repeat reliably. GitHub Actions can build, test, and release a known commit to a VPS whenever changes reach your deployment branch.

The example assumes the VPS has a deployment script and the application is managed by PM2 or Docker.

Prepare a deployment account

Create a dedicated user with only the permissions needed to update the application. Generate a dedicated CI key locally:

ssh-keygen -t ed25519 -f ~/.ssh/myapp_deploy -C "github-actions-myapp"

Add the public key to the deployment user’s authorized_keys. Store the private key only in GitHub Actions secrets.

Add repository secrets

Typical secrets are VPS_HOST, VPS_USER, VPS_SSH_KEY, and VPS_PORT. Keep production secrets in the GitHub Environment for production and require approval when that matches your release policy.

A small SSH deployment workflow

Create .github/workflows/deploy.yml:

name: Deploy

on:
  push:
    branches: [main]

concurrency:
  group: production
  cancel-in-progress: false

jobs:
  deploy:
    runs-on: ubuntu-latest
    environment: production
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: 22
          cache: npm
      - run: npm ci
      - run: npm test --if-present
      - run: npm run build
      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1.2.1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT || 22 }}
          script: /opt/myapp/deploy.sh ${{ github.sha }}

Pin third-party actions to a reviewed commit in a high-security environment. Do not echo secrets or use untrusted pull-request input in shell commands.

Make the server-side script idempotent

/opt/myapp/deploy.sh should validate its argument, fetch the exact revision or image, build outside the live process, reload the service, and fail when the health check fails. A release script should be safe to run again after a network interruption.

For a PM2 application, the core might look like:

set -euo pipefail
release_sha="${1:?release SHA is required}"
cd /var/www/myapp
git fetch origin main
git checkout "$release_sha"
npm ci
npm run build
pm2 reload myapp --update-env
curl --fail --max-time 10 http://127.0.0.1:3000/health

Use a release directory and an atomic symlink switch when builds must never touch the running release.

Use concurrency and health checks

Concurrency prevents two production releases from racing. A health check should verify the application responds with the expected status after the process reload, not only during the CI build:

curl --fail --max-time 10 https://example.com/health

If the check fails, preserve logs and return a non-zero exit status so the workflow is visibly failed.

Docker alternative

For Docker deployments, build and push an image tagged with ${{ github.sha }} to a registry, then let the server pull that exact tag and run docker compose up -d. Avoid relying only on latest, because an immutable tag makes auditing and rollback possible.

Rollback plan

Keep the previous release or image available. A rollback should be a documented command, not a last-minute edit on the server:

git checkout <previous-known-good-sha>
npm ci
npm run build
pm2 reload myapp

Check database migrations separately. Application code may be reversible while a destructive schema migration is not.

Security checklist

  • Use a dedicated deploy user and a dedicated SSH key.
  • Restrict the key to the deployment account.
  • Keep secrets in encrypted GitHub storage.
  • Verify the host key instead of disabling host-key checking.
  • Do not print environment files, tokens, or private keys.
  • Restrict workflow permissions to the minimum required.
  • Require approval for production when appropriate.
  • Record the deployed commit and provide a rollback path.

Continue reading