"CI/CD" tends to conjure Jenkins clusters and platform teams, which is why a lot of solo founders skip it entirely and deploy by SSHing in and running a manual pull-and-restart. That works until it doesn't — a deploy done half-asleep, or a forgotten migration step, is a common source of avoidable production incidents. A minimal pipeline removes the manual-error surface without requiring any new infrastructure.
What manual deploys actually cost you
Beyond the obvious risk of a fat-fingered command, manual deploys mean deploys don't happen unless you're at your keyboard and remember every step in the right order — which is exactly when things get skipped under time pressure. Automating it means the same correct sequence runs every time, whether you're deploying at your desk or from your phone approving a merge.
The simplest version: a git push hook
The lightest possible setup: a bare Git repository on your VPS with a post-receive hook that checks out the new code and restarts your app whenever you push to it directly.
# on the VPS
git init --bare /var/repo/app.git
# in /var/repo/app.git/hooks/post-receive
#!/bin/bash
GIT_WORK_TREE=/var/www/app git checkout -f main
cd /var/www/app && npm install --production && pm2 restart app-one
You then add this as a second Git remote locally and git push production main to deploy. It's simple and dependency-free, but it has no test step and no visibility into whether the deploy actually succeeded beyond watching the terminal output.
A real pipeline with GitHub Actions
GitHub Actions (free for reasonable usage on public and most private repos) adds a test step and a clean audit trail without needing any infrastructure beyond your existing GitHub repo:
# .github/workflows/deploy.yml
name: Deploy
on:
push:
branches: [main]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: npm install
- run: npm test
- name: Deploy over SSH
uses: appleboy/ssh-action@v1
with:
host: ${{ secrets.VPS_HOST }}
username: ${{ secrets.VPS_USER }}
key: ${{ secrets.VPS_SSH_KEY }}
script: |
cd /var/www/app
git pull origin main
npm install --production
pm2 restart app-one
Store the SSH host, username, and a dedicated deploy key (not your personal key) as encrypted GitHub Secrets rather than in the workflow file itself. This gives you: tests that must pass before anything deploys, a full log of every deploy with what changed, and a deploy that happens automatically the moment code lands on main — matching the reverse-proxy and PM2 setup from our Nginx + PM2 guide.
Running migrations safely in the pipeline
Database migrations deserve more caution than a blind automatic step, since a bad migration is harder to undo than a bad code deploy. A reasonable middle ground: have the pipeline run migrations automatically for additive, backward-compatible changes (new nullable columns, new tables), but treat destructive changes (dropping columns, renaming things in ways that break the previous code version) as a manual step you run deliberately, separate from the automatic deploy.
Rolling back when a deploy goes wrong
Because every deploy is a Git commit, rolling back is reverting to the previous commit and re-running the same pipeline — no special rollback tooling required. Keep this fast by making sure your pipeline can redeploy an older commit just as easily as the latest one, and by avoiding destructive migrations that would make an old code version incompatible with the current database state.
Frequently asked questions
Do I need Docker for this to work?
No — the SSH-and-restart approach works identically whether your app runs bare-metal under PM2 (as shown here) or in containers; only the final deploy script's commands change. See our Docker vs bare metal guide if you're still deciding between the two.
What if my VPS doesn't allow inbound SSH from GitHub's IP ranges?
GitHub Actions runners use dynamic IPs, so IP-allowlisting your SSH port specifically for this purpose isn't practical. Use a dedicated deploy key with limited permissions (ideally restricted via authorized_keys to only run the deploy command, using a forced command) rather than opening SSH more broadly.
Is this actually safer than deploying by hand?
Yes, primarily because it's consistent — the exact same steps run in the exact same order every time, with test failures blocking a bad deploy automatically. The main new risk it introduces is the deploy credentials themselves, which is why they belong in encrypted secrets, not the repository.