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.
Article by Phạm Minh Thiện
Front-end developer working directly with Next.js, NestJS, Docker, Nginx, and Ubuntu VPS deployments.
Reviewed on 7/21/2026.
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
Related articles
How to Deploy Docker to a VPS with GitHub Actions
Build a Docker image with GitHub Actions, publish it to GHCR, and deploy a tested release to an Ubuntu VPS with a safe rollback path.
Read article →Deploy Node.js to a VPS with PM2 and Nginx from A to Z
A practical Node.js deployment guide for Ubuntu using PM2 and Nginx, including builds, environment variables, HTTPS, logs, health checks, and rollback.
Read article →Safely Fix SSH Permission Denied and Host Key Changed Errors
Check the correct SSH user, private key, authorized_keys, file permissions, and server fingerprint before repairing publickey or known_hosts errors.
Read article →