If you're still deploying code to your VPS by SSHing in and running git pull by hand, you already know the failure modes: someone forgets a step, a file gets overwritten with local edits, or the deploy happens at 11pm and nobody remembers what changed. Wiring up GitHub Actions to push straight to your SkyServer VPS over SSH fixes most of that, and it's a lot less work to set up than people expect.
This walkthrough covers a plain rsync-over-SSH deploy from GitHub Actions — no Kubernetes, no container registry, just a workflow file, a deploy key, and a target directory on your VPS. It works for Node apps, static sites, Laravel, Django, whatever you're running.
Symptom: manual deploys are slow, risky, or just don't happen
The usual signs you've outgrown manual deploys:
- Deploys only happen when one specific person is around, because they're the one who remembers the steps.
- A deploy occasionally clobbers a config file or
.envthat was hand-edited on the server. - There's no record of what commit is actually live — "checking" means SSHing in and running
git log -1. - Nobody wants to deploy on a Friday, so bug fixes sit for days.
None of this is really about GitHub Actions specifically — it's about not having a repeatable, logged deploy path. Actions is just a convenient way to get one without standing up a separate CI server.
Cause: there's no defined path from "merge" to "server"
Most small teams start with SSH because it's the fastest way to get a site live, and then never revisit it once the project is running in production. That's fine for a while. It stops being fine once more than one person touches the server, or once a bad deploy takes the site down at a bad time with no easy way to see what changed.
Fix: a GitHub Actions workflow that deploys over SSH on every push
1. Generate a dedicated deploy key
Don't reuse your personal SSH key. Generate a new keypair just for CI, on your own machine:
ssh-keygen -t ed25519 -C "github-actions-deploy" -f deploy_key -N ""
This gives you deploy_key (private) and deploy_key.pub (public).
2. Create a low-privilege deploy user on the VPS
Don't deploy as root. Create a user that only has write access to the app directory:
adduser deploy
usermod -aG www-data deploy
mkdir -p /home/deploy/.ssh
cat deploy_key.pub >> /home/deploy/.ssh/authorized_keys
chmod 700 /home/deploy/.ssh
chmod 600 /home/deploy/.ssh/authorized_keys
chown -R deploy:deploy /home/deploy/.ssh
Give that user ownership (or group write) on the deploy target, e.g. /var/www/myapp, and nothing else.
3. Add the private key and connection details as GitHub secrets
In your repo, go to Settings → Secrets and variables → Actions and add:
| Secret name | Value |
|---|---|
| VPS_HOST | your VPS IP or hostname |
| VPS_USER | deploy |
| VPS_SSH_KEY | contents of deploy_key (the private key) |
| VPS_PATH | /var/www/myapp |
Never paste the private key directly into the workflow file — secrets are the whole point here.
4. Write the workflow file
Create .github/workflows/deploy.yml:
name: Deploy to VPS
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Install SSH key
uses: webfactory/ssh-agent@v0.9.0
with:
ssh-private-key: ${{ secrets.VPS_SSH_KEY }}
- name: Add VPS to known_hosts
run: ssh-keyscan -H ${{ secrets.VPS_HOST }} >> ~/.ssh/known_hosts
- name: Deploy via rsync
run: |
rsync -avz --delete \
--exclude '.git*' \
--exclude 'node_modules' \
./ ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }}:${{ secrets.VPS_PATH }}
- name: Restart app
run: |
ssh ${{ secrets.VPS_USER }}@${{ secrets.VPS_HOST }} \
"cd ${{ secrets.VPS_PATH }} && npm install --production && pm2 restart myapp"
Adjust the last step for your stack — that could be composer install and a queue restart for Laravel, or just nothing at all for a static site. The point is the same: rsync the files, then run whatever the app needs to pick up the change.
5. Test it with a trivial commit
Push a small, harmless change to a comment or README on the main branch and watch the Actions tab. If the workflow goes green but the site doesn't update, check three things in order: the rsync path actually matches where your web server serves from, the deploy user has write permission on every file rsync touches, and the restart command's working directory is correct.
Prevention: keep the deploy path boring
- Use a staging branch first. Point a second workflow at a
stagingbranch and a separate directory/subdomain before anything hitsmain. - Never store real secrets in the repo. Keep
.envfiles on the server, out of the rsync exclude list is fine, but don't let CI overwrite them — add--exclude '.env'to the rsync command. - Rotate the deploy key if it's ever exposed in a log, a fork, or a screen share. It's cheap to regenerate and only touches one low-privilege account.
- Keep a rollback path. Tag releases in git so you can redeploy an older commit manually if a bad push slips through.
- Watch your VPS resources. An automated deploy that runs a build step on the server itself (rather than in CI) can spike CPU/RAM on a small VPS — consider building in GitHub Actions and rsyncing only the built artifacts for anything heavier than a simple app.
Once this is running, deploys become "push to main, wait two minutes, check the site" — no SSH session, no manual steps, and a full log of exactly what shipped and when.
Frequently Asked Questions
Do I need a container or Docker for this to work?
No. Plain rsync over SSH works fine for most apps and is simpler to debug than a container pipeline. Docker is worth it once you have multiple services or need identical environments across staging and production, but it's not a prerequisite for automated deploys.
Can I use a password instead of an SSH key for the deploy secret?
You can, but don't. SSH keys can be scoped to one user with no shell access if you want, and they're far easier to revoke than rotating a password that might be reused elsewhere. Stick with a dedicated keypair.
What if my VPS is behind a firewall that only allows specific IPs on port 22?
GitHub Actions runners use a large, changing range of IP addresses, so a static allowlist won't work reliably. Either open port 22 to GitHub's published IP ranges (published in their meta API and updated regularly) or use a self-hosted runner installed directly on your VPS, which avoids the external connection entirely.
How do I handle database migrations in this workflow?
Add a step after the rsync that runs your migration command over SSH — for example php artisan migrate --force for Laravel. Run it only on pushes to main, and always take a database snapshot beforehand for anything schema-changing.
What happens if the deploy fails halfway through?
Because rsync only copies changed files, a failed run can leave the app directory in a mixed state. For anything more than a static site, deploy to a fresh release directory (like Capistrano-style releases) and swap a symlink once the rsync finishes cleanly — that way a half-finished transfer never becomes the live version.
