Deploy to a VPS With GitHub Actions: Push to Main, Ship to Production
Automate deploys to your own server: run tests in GitHub Actions, then SSH in and run your deploy script — or build a Docker image and pull it. Deploy keys, secrets, known_hosts, environments with approval, rollbacks, and keeping the SSH key's power limited.
If you deploy by SSHing into your server and running commands by hand, you've probably forgotten a step at least once. GitHub Actions can do it for you on every push to main — after the tests pass.
This builds on CI with GitHub Actions and assumes you already have a working manual deploy, e.g. from deploying a Node app to a VPS.
The two common approaches
- SSH and pull: Actions connects to the server and runs a deploy script that pulls the code, installs, builds and restarts. Simple; the server does the building.
- Build image, server pulls: Actions builds a Docker image, pushes it to a registry, then tells the server to pull and restart. The server never builds; deploys are faster and more reproducible.
We'll do the first in full and sketch the second.
Step 1: A deploy script on the server
/srv/app/deploy.sh:
#!/usr/bin/env bash
set -euo pipefail
cd /srv/app
git fetch --quiet origin main
git reset --hard origin/main
npm ci
npm run build
npm run migrate
pm2 reload app --update-env
echo "Deployed $(git rev-parse --short HEAD)"
chmod +x /srv/app/deploy.sh
Run it by hand once to make sure it works. (Bash scripting for beginners)
Step 2: A dedicated SSH key for deploys
On your computer, create a key used only by GitHub Actions:
ssh-keygen -t ed25519 -f deploy_key -N "" -C "github-actions-deploy"
Add deploy_key.pub to the server's ~/.ssh/authorized_keys for the deploy user — and restrict what it can do:
command="/srv/app/deploy.sh",no-port-forwarding,no-agent-forwarding,no-pty ssh-ed25519 AAAA… github-actions-deploy
With command=, this key can only run the deploy script, whatever the client asks for. If the key leaks, an attacker can redeploy your own code — not open a shell.
Step 3: GitHub secrets
In the repo: Settings → Secrets and variables → Actions, add:
DEPLOY_SSH_KEY— contents of the privatedeploy_key.DEPLOY_HOST— the server's hostname or IP.DEPLOY_KNOWN_HOSTS— the server's host key, fromssh-keyscan your-server.com. Pinning it prevents man-in-the-middle attacks; don't disable host key checking.
Delete the local deploy_key files afterwards, or store them in your password manager.
Step 4: The workflow
.github/workflows/deploy.yml:
name: Test and deploy
on:
push:
branches: [main]
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: 24
cache: npm
- run: npm ci
- run: npm test
deploy:
needs: test
runs-on: ubuntu-latest
environment: production
steps:
- name: Configure SSH
run: |
mkdir -p ~/.ssh
echo "${{ secrets.DEPLOY_SSH_KEY }}" > ~/.ssh/deploy_key
chmod 600 ~/.ssh/deploy_key
echo "${{ secrets.DEPLOY_KNOWN_HOSTS }}" > ~/.ssh/known_hosts
- name: Deploy
run: ssh -i ~/.ssh/deploy_key deploy@${{ secrets.DEPLOY_HOST }}
(Use the current major versions of the official actions — check their repositories.)
What matters here:
needs: test— no deploy unless tests pass.concurrency— two quick pushes don't run two deploys at once.environment: production— lets you add required reviewers (a manual "approve deploy" button), environment-specific secrets, and a deploy history in GitHub.- The SSH command has no arguments: the
command=restriction runs the script.
Step 5: Don't deploy broken builds
Order inside deploy.sh matters. Install and build before restarting; with set -e, a failed build stops the script and the old version keeps serving. For true zero-downtime, build into a new release folder and switch a symlink, or use PM2's cluster reload. (Zero-downtime deploys)
After the restart, check health:
curl --fail --retry 5 --retry-delay 2 http://127.0.0.1:3000/healthz
Rollbacks
Keep it simple: revert the bad commit on main (git revert) and push — the pipeline redeploys the previous code. For faster rollbacks, keep the last few release folders or image tags and switch back. (Undo almost anything in git)
The Docker variant (sketch)
- In Actions, build and push to GitHub Container Registry (
docker/build-push-action), tagged with the commit SHA. - On the server, the restricted deploy command runs
docker compose pull && docker compose up -d. - Roll back by deploying a previous tag.
Security notes
- Secrets are masked in logs, but don't
echothem or pass them to untrusted actions. - Pin third-party actions to a version (or commit SHA) you trust. (npm supply chain security — the same thinking applies.)
- Workflows triggered by pull requests from forks don't get secrets — keep deploys on
pushtomain.
The summary
- Write a deploy script on the server; run it by hand first.
- Use a dedicated SSH key restricted with
command=to only run that script. - Store key, host and known_hosts as secrets; never disable host key checking.
- Gate deploys on tests, use
concurrencyand a protectedproductionenvironment. - Build before restart, health-check after, and roll back by reverting.
EasySpawn gives you a server where Claude Code can deploy directly — no SSH keys in CI required — with SSL, Postgres and daily backups handled. See how it works or join the waitlist.
Related: Set Up CI With GitHub Actions · What Is CI/CD? · Zero-Downtime Deploys · Claude Code GitHub Actions
Keep reading
Set Up CI With GitHub Actions in Ten Minutes
Continuous integration runs your checks on every push and pull request, so broken code is caught before it merges — whoever, or whatever, wrote it. A working GitHub Actions workflow for a Node project, what each line does, how to make checks required, and the mistakes that make CI slow or insecure.
How to Test Webhooks Locally: Stripe CLI, Tunnels, and Replays
Webhook providers can't reach localhost, so local testing needs a forwarder or a tunnel. How to use provider CLIs like stripe listen, general tunnels like ngrok and Cloudflare Tunnel, request-capture tools, and fixture replays in automated tests — plus the signature-verification gotchas.