The strategy
Two ways to deploy from GitHub Actions to your VPS: a GitHub-hosted runner pushing over SSH (covered here, the most universal), or a self-hosted runner installed on the VPS (discussed at the end). Either way, the goal is zero-downtime deployment through symlinked releases.
Prerequisites
Your application already runs once on the VPS (see our guide "Deploy a Node.js application in production"), served by a non-root deploy user. The repository is on GitHub.
Step 1 — A dedicated deployment SSH key
On your workstation, generate a key pair used only by CI:
ssh-keygen -t ed25519 -f ~/.ssh/github-deploy -C "github-actions"Add the public key on the VPS:
cat ~/.ssh/github-deploy.pub | ssh deploy@VPS-IP "cat >> ~/.ssh/authorized_keys"In the GitHub repository: Settings → Secrets and variables → Actions → New repository secret. Create VPS_HOST (the IP), VPS_USER (deploy), VPS_PORT (22 or your custom port) and VPS_SSH_KEY (the full private key, including the BEGIN/END lines).
Step 2 — The zero-downtime layout on the VPS
sudo mkdir -p /var/www/myapp/releases /var/www/myapp/shared
sudo chown -R deploy:deploy /var/www/myappEach deployment creates a timestamped folder inside releases/. The shared/ folder holds what survives across versions: the .env, uploaded files. A current symlink points to the live release — Nginx or PM2 never see an interruption. Move your .env into shared/ right now.
Step 3 — The deployment script
Create /home/deploy/deploy.sh on the VPS:
#!/usr/bin/env bash
set -euo pipefail
APP_DIR=/var/www/myapp
RELEASE=$(date +%Y%m%d%H%M%S)
mkdir -p "$APP_DIR/releases/$RELEASE"
tar -xzf /tmp/release.tar.gz -C "$APP_DIR/releases/$RELEASE"
ln -sfn "$APP_DIR/shared/.env" "$APP_DIR/releases/$RELEASE/.env"
cd "$APP_DIR/releases/$RELEASE"
npm ci --omit=dev
npm run build
ln -sfn "$APP_DIR/releases/$RELEASE" "$APP_DIR/current"
pm2 reload myapp
cd "$APP_DIR/releases"
ls -1dt */ | tail -n +6 | xargs -r rm -rfThe switch is atomic: ln -sfn replaces the symlink in a single operation. The last five releases are kept, older ones removed. Make the script executable: chmod +x /home/deploy/deploy.sh.
Step 4 — The GitHub Actions workflow
Create .github/workflows/deploy.yml in your repository:
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build
run: |
npm ci
npm run build
tar -czf release.tar.gz dist package.json package-lock.json
- name: Upload to VPS
uses: appleboy/[email protected]
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
source: release.tar.gz
target: /tmp/
- name: Deploy
uses: appleboy/[email protected]
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
port: ${{ secrets.VPS_PORT }}
script: /home/deploy/deploy.shEvery push to main builds the archive, transfers it and runs the script. On the first deployment, watch the Actions tab — every step is logged.
Step 5 — Rollback in ten seconds
The symlink makes rollback trivial — list the releases and point back:
ls /var/www/myapp/releases
ln -sfn /var/www/myapp/releases/20241015120000 /var/www/myapp/current
pm2 reload myappAlternative — self-hosted runner
Install the runner on the VPS (Settings → Actions → Runners → New self-hosted runner) and the workflow becomes a plain git pull && npm ci && pm2 reload executed locally: faster, no network transfer. Keep this option for private repositories where you are the only contributor — a self-hosted runner on a public repo can execute arbitrary third-party code on your server.
Security
- The deployment key is used nowhere else; revoking it means deleting one line from
authorized_keys. - The
.envlives inshared/, never in the archive or the repository. - Watch the workflow logs: GitHub secrets are masked, but a misplaced
set -xcan leak paths.
Troubleshooting
- Permission denied (publickey): the
VPS_SSH_KEYsecret must hold the complete private key. Test the key manually:ssh -i ~/.ssh/github-deploy deploy@VPS-IP. - `pm2: command not found` inside the script: the PATH of a non-interactive SSH session is minimal — use the full path
$(which pm2)or export PATH at the top of the script. - The site still serves the old version: check
ls -l /var/www/myapp/currentand reload the application, not just Nginx.
Going further
The same release pattern works for any stack: swap npm ci for composer install or pip install -r requirements.txt, and pm2 reload for systemctl restart myapp. Add a workflow_dispatch entry under on: in the workflow to enable manual deployments from the Actions tab without pushing a commit — handy for redeploying after rotating a secret or patching the server.