Skip to content
Products
AI Website Builder New VPS Hosting Cloud Servers Web Hosting Dedicated Servers Domains
Company
About Documentation Support Center Contact Get Started Login Client Area
ALL SYSTEMS OPERATIONAL
VPS

Deploy to a VPS with GitHub Actions: A CI/CD Guide

Getwebup 6 min read

If your deploy process is still "SSH in, git pull, restart the app, hope nothing broke," you've probably already been burned by it once — a forgotten restart, a fat-fingered command, or a 2 a.m. deploy that nobody wanted to do. Wiring GitHub Actions to push straight to your VPS fixes that, and it's less work to set up than most people expect.

What You're Actually Building

The pattern is simple: you push to a branch (usually main), GitHub Actions spins up a runner, SSHes into your VPS with a key that only that workflow has, pulls the latest code (or rsyncs a build), and restarts whatever process serves your app — PM2, systemd, or a Docker container. No FTP, no manual steps, no "did I actually push that file" guessing games.

This guide assumes a Node.js or PHP app running behind Nginx on an Ubuntu VPS, but the same shape works for almost anything you'd deploy over SSH.

Step 1: Create a Dedicated Deploy User

Don't deploy as root, and don't reuse your personal login. Make a low-privilege user just for this:

adduser deploy
usermod -aG www-data deploy

Give it write access only to the directories it needs — your app's folder and nothing else. If a deploy key ever leaks, you want the blast radius to be small.

Step 2: Generate a Deploy-Only SSH Key

Generate a fresh keypair on your own machine — don't reuse a key you already use elsewhere:

ssh-keygen -t ed25519 -C "github-actions-deploy" -f ./deploy_key -N ""

Add the public key to the deploy user's authorized_keys on the VPS:

ssh deploy@your-vps-ip
mkdir -p ~/.ssh && chmod 700 ~/.ssh
echo "ssh-ed25519 AAAA... github-actions-deploy" >> ~/.ssh/authorized_keys
chmod 600 ~/.ssh/authorized_keys

The private key (deploy_key) goes into a GitHub Actions secret — never into the repo, never in a commit, not even in a private one.

Step 3: Store Secrets in GitHub

In your repo, go to Settings → Secrets and variables → Actions and add:

Secret nameValue
VPS_HOSTYour server's IP or hostname
VPS_USERdeploy
VPS_SSH_KEYThe private key content, in full, including the header/footer lines
VPS_PORTYour SSH port, if you've changed it from 22

Step 4: Write the Workflow

Create .github/workflows/deploy.yml:

name: Deploy to VPS
on:
  push:
    branches: [main]

jobs:
  deploy:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Deploy over SSH
        uses: appleboy/ssh-action@v1
        with:
          host: ${{ secrets.VPS_HOST }}
          username: ${{ secrets.VPS_USER }}
          key: ${{ secrets.VPS_SSH_KEY }}
          port: ${{ secrets.VPS_PORT }}
          script: |
            cd /var/www/myapp
            git pull origin main
            npm ci --omit=dev
            pm2 reload myapp

Swap the script block for whatever your stack needs — composer install --no-dev for PHP, a docker compose pull && docker compose up -d for containers, or an rsync step if you'd rather ship a built artifact than pull source on the server.

Common Failures and How to Fix Them

Symptom: "Permission denied (publickey)"

Cause: Either the public key never made it into authorized_keys correctly, or the private key secret got mangled — GitHub secrets sometimes strip trailing newlines, which breaks PEM-formatted keys.

Fix: Paste the entire private key including -----BEGIN OPENSSH PRIVATE KEY----- and the closing line, with no extra leading/trailing whitespace. Test the exact same key locally first: ssh -i deploy_key deploy@your-vps-ip. If that works and the Action still fails, check file permissions on the server — ~/.ssh must be 700 and authorized_keys must be 600, or sshd will silently ignore it.

Symptom: "Host key verification failed"

Cause: The runner has never connected to your VPS before, so it doesn't have your host key cached, and strict host checking rejects the connection.

Fix: Most SSH actions (including appleboy/ssh-action) skip host-key checking by default for CI convenience. If you're rolling your own step with plain ssh, add a step to fetch and trust the host key first:

- run: ssh-keyscan -p ${{ secrets.VPS_PORT }} ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts

Symptom: The workflow goes green, but the site doesn't change

Cause: Nine times out of ten, the app process never actually restarted — git pull updated the files on disk, but Node/PHP kept serving the old code from memory or from opcache.

Fix: Make sure your script block ends with an actual restart or reload command (pm2 reload, systemctl restart myapp, or clearing PHP OPcache). Also check you're pulling into the same directory Nginx is actually pointed at — a surprising number of "it didn't deploy" tickets turn out to be two copies of the app on disk.

Symptom: The job hangs or times out on npm install

Cause: Running the build step on a small VPS (1 GB RAM or less) alongside a live Node process can exhaust memory, and npm just stalls instead of failing cleanly.

Fix: Build in the GitHub Actions runner instead of on the server, then rsync the built dist/ or build/ folder over. It's faster and it stops your deploy from fighting your live traffic for RAM.

Zero-Downtime Restarts

A plain pm2 restart or systemctl restart drops connections for a second or two while the process comes back up. For anything customer-facing, prefer:

  • PM2: pm2 reload myapp — spins up new workers before killing old ones (needs cluster mode).
  • systemd + Gunicorn/Unicorn: send SIGHUP or use a socket-activated service so the old process keeps serving until the new one's ready.
  • Docker: run a second container on a new tag and switch Nginx's upstream, rather than stopping the old one first.

Prevention: Keep the Deploy Path Locked Down

  • Restrict the deploy key so it can only run the one command you need, using a command= prefix in authorized_keys — that way a stolen key can't be used to poke around the server.
  • Use GitHub's Environments feature to require a manual approval before production deploys, even if main pushes trigger staging automatically.
  • Rotate the deploy key every few months, and immediately if anyone with repo access leaves the team.
  • Keep a rollback plan — tag releases in Git so git checkout <previous-tag> && pm2 reload is a 30-second fix, not a fire drill.

Once this is in place, "deploy" stops being an event and turns into background noise — which is exactly what you want.

Frequently asked questions

Do I need Docker to use GitHub Actions with a VPS?

No. GitHub Actions just needs SSH access to run commands on your server, so a plain git pull plus a process restart works fine. Docker is only useful here if you already containerize your app and want to ship images instead of source files.

Is it safe to put my VPS SSH key in GitHub Secrets?

Yes, as long as it's a dedicated deploy-only key with restricted permissions on the server, not your personal SSH key. GitHub Secrets are encrypted at rest and masked in workflow logs, but you should still scope the key narrowly and rotate it periodically.

Can I deploy to multiple environments, like staging and production?

Yes — use separate secrets and workflow triggers per branch (for example, push to staging deploys automatically, push to main requires manual approval via GitHub Environments). Keep each environment's deploy user and directory separate on the server.

Why did my deploy succeed in the Actions log but the site still shows old content?

Usually the files updated but the running process or a cache (PHP OPcache, a CDN, browser cache) is still serving the old version. Confirm your script actually restarts or reloads the app process, and check you're deploying into the directory your web server is actually pointed at.

What's the difference between pm2 restart and pm2 reload?

restart stops the process and starts a new one, causing a brief gap in availability. reload (in PM2 cluster mode) starts new workers and only kills the old ones once the new ones are ready, so there's no dropped traffic during a deploy.

#github-actions #ci-cd #vps #ssh-deploy-keys #devops #automated-deployment

Keep reading

Chat with Support