Blog
4 min read

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

  1. 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.
  2. 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 private deploy_key.
  • DEPLOY_HOST — the server's hostname or IP.
  • DEPLOY_KNOWN_HOSTS — the server's host key, from ssh-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

(Health check endpoints)

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)

  1. In Actions, build and push to GitHub Container Registry (docker/build-push-action), tagged with the commit SHA.
  2. On the server, the restricted deploy command runs docker compose pull && docker compose up -d.
  3. Roll back by deploying a previous tag.

Security notes

  • Secrets are masked in logs, but don't echo them 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 push to main.

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 concurrency and a protected production environment.
  • 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