Auto deploy from GitHub to a VPS: webhooks, Actions, zero downtime

Three ways to auto deploy from GitHub to a VPS: GitHub Actions over SSH, webhooks, or a panel with Git deploy, plus release scripts, health checks and rollback.

Diagram: a git push to GitHub triggering a build and a release switch on a VPS

Key takeaways

  • There are three ways to auto deploy from GitHub to a VPS - GitHub Actions SSHing into the server, a webhook calling the server, or a panel with built-in Git deploy.
  • Webhooks must be verified with the HMAC-SHA256 signature in X-Hub-Signature-256, using a constant-time comparison.
  • GitHub deploy keys are read-only by default and tied to a single repository.
  • Build each release in its own folder and switch traffic only after a health check, so rollback is just moving a pointer back.

There are three ways to auto deploy from GitHub to a VPS on every push: GitHub Actions SSHing into the server to run a deploy script, a GitHub webhook calling an endpoint on the server, or a panel with built-in Git deploy that handles the webhook, build, health check and rollback for you. Whichever you choose, a trustworthy pipeline needs four things: deploy only the right branch, authenticate the caller, build the new release somewhere separate, and switch traffic only once the new release actually works.

This guide compares the three approaches, gives you a ready-to-use GitHub Actions workflow and deploy script (with locking, a health check and rollback), explains how to verify webhooks properly, and shows how it works on ZoPanel.

Auto deploy from GitHub: which approach?

Approach Pros Cons
GitHub Actions + SSH Run tests before deploying, logs stay on GitHub, no new endpoint on the server An SSH key lives in GitHub Secrets; you write the release and rollback logic
Webhook to the server Fast, no waiting for a GitHub runner You build and protect the endpoint, signatures, duplicates and concurrency yourself
Panel with Git deploy Webhook, sandboxed build, health check and rollback built in Tied to the panel; customisation within what it supports

One rule for all of them: never run the deploy as root, and never let "deploy" mean git pull into the live directory. That gives you no rollback, and a broken commit is in production instantly.

Option 1: GitHub Actions deploying over SSH

Step 1: A deploy user and two key pairs

You need two different key pairs, which people often mix up:

  • Key A (GitHub Actions → server): used by Actions to SSH in. The private key lives in GitHub Secrets; the public key goes in the deploy user's authorized_keys.
  • Key B (server → GitHub): used by the server to clone a private repository. Its public key is added to the repository as a deploy key. Per GitHub's documentation, deploy keys are read-only by default and cannot be reused across repositories.
sudo adduser --disabled-password --gecos "" deploy
sudo -u deploy ssh-keygen -t ed25519 -f /home/deploy/.ssh/id_ed25519 -N ""   # key B
sudo cat /home/deploy/.ssh/id_ed25519.pub   # add to the repo: Settings → Deploy keys

Step 2: Key A may run exactly one command

On your own machine, create key A (ssh-keygen -t ed25519 -f gha_deploy -N ""). Add its public key to /home/deploy/.ssh/authorized_keys with restrictions:

restrict,command="/srv/myapp/deploy.sh" ssh-ed25519 AAAA...key-A... github-actions

restrict disables port forwarding, agent forwarding and a terminal; command= forces every login with this key to run the deploy script and nothing else. If key A leaks, an attacker can only redeploy your own repository, with no shell.

Step 3: A deploy script with releases, a health check and rollback

/srv/myapp/deploy.sh (owned by deploy, mode 750):

#!/usr/bin/env bash
set -euo pipefail
APP=/srv/myapp
REPO=git@github.com:you/myapp.git
REL="$APP/releases/$(date +%Y%m%d%H%M%S)"

exec 9>"$APP/deploy.lock"
flock -n 9 || { echo "Another deploy is running" >&2; exit 1; }

PREV=""
[ -L "$APP/current" ] && PREV=$(readlink -f "$APP/current")
mkdir -p "$APP/releases"
git clone --depth 1 --branch main "$REPO" "$REL"
cd "$REL"
npm ci && npm run build            # replace with your project's build

ln -sfn "$REL" "$APP/current.new" && mv -Tf "$APP/current.new" "$APP/current"
sudo /usr/bin/systemctl restart myapp.service

if ! curl -fsS --retry 10 --retry-delay 2 --retry-connrefused \
     http://127.0.0.1:3000/health > /dev/null; then
  echo "Health check failed, rolling back" >&2
  if [ -n "$PREV" ]; then
    ln -sfn "$PREV" "$APP/current.new" && mv -Tf "$APP/current.new" "$APP/current"
    sudo /usr/bin/systemctl restart myapp.service
  fi
  exit 1
fi

ls -1dt "$APP"/releases/* | tail -n +6 | xargs -r rm -rf   # keep 5 releases

The myapp.service systemd unit runs from /srv/myapp/current. Let the deploy user restart that one service and nothing more (create the file with sudo visudo -f /etc/sudoers.d/myapp-deploy):

deploy ALL=(root) NOPASSWD: /usr/bin/systemctl restart myapp.service

Swapping the symlink with mv -T is atomic: current never points at a half-built folder. There is still a few seconds' gap while the service restarts, though. True zero downtime means starting the new release alongside the old one on another port and switching nginx's upstream (blue/green), which is what the panel in option 3 does for you.

Step 4: The GitHub Actions workflow

Store the server's known_hosts entry in a secret so Actions cannot be fooled by an impostor: run ssh-keyscan -t ed25519 203.0.113.10 from a network you trust, check the fingerprint matches the server, and save it as DEPLOY_KNOWN_HOSTS. Save key A's private key as DEPLOY_SSH_KEY.

# .github/workflows/deploy.yml
name: Deploy
on:
  push:
    branches: [main]
concurrency:
  group: deploy-production
  cancel-in-progress: false
jobs:
  test:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci && npm test
  deploy:
    needs: test
    runs-on: ubuntu-latest
    steps:
      - name: Deploy over SSH
        env:
          SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
          KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
        run: |
          install -m 700 -d ~/.ssh
          printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
          chmod 600 ~/.ssh/id_ed25519
          printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
          ssh deploy@203.0.113.10

Because key A is forced to run deploy.sh, a bare ssh is enough. concurrency makes sure two quick pushes never deploy on top of each other. If you run Fail2ban, remember GitHub's runners use changing IPs; successful key logins are not counted as failures.

Option 2: A GitHub webhook calling your server

Webhooks are faster than Actions because there is no runner to wait for, but your endpoint is public, so do it properly:

  1. Verify the signature. GitHub sends X-Hub-Signature-256, an HMAC-SHA256 of the body using your secret. According to GitHub's docs, compare it with a constant-time function (such as Python's hmac.compare_digest), not ==. The older X-Hub-Signature header uses SHA-1 and exists only for backward compatibility.
  2. Never put the secret in the URL. URLs end up in proxy and browser logs.
  3. Check the branch in the payload (ref equal to refs/heads/main) and ignore everything else.
  4. Answer fast, deploy later. The endpoint queues the job and returns 200; the build runs in the background. GitHub waits only a limited time for a response, and a long build would be reported as a failed delivery.
  5. Handle duplicates and concurrency. GitHub can redeliver an event; use the X-GitHub-Delivery header to drop repeats, and a lock such as the flock above.
import hmac, hashlib

def valid_signature(secret: bytes, body: bytes, header: str) -> bool:
    expected = "sha256=" + hmac.new(secret, body, hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, header or "")

Run the endpoint as a non-root user, and have it do one thing only: call the deploy script from option 1.

Auto deploy from GitHub with ZoPanel

If your VPS runs ZoPanel, Git deploy handles the hard parts above:

  1. Create the site in Websites → New website, choose Git deploy, and enter the Repository URL and Branch. ZoPanel detects the framework (Next.js, Nuxt, Laravel, Django, Flask, Go, Rails and more) and proposes install, build and start commands. For a private repository, click Show deploy key and add it to the repository as a read-only deploy key.
  2. On the Deploy tab, in the Automatic deploys card, turn on Deploy when main is pushed and copy the Payload URL and Secret.
  3. On GitHub: Settings → Webhooks, paste the URL, choose content type application/json and enter the secret.

ZoPanel verifies the X-Hub-Signature-256 signature (or the token header for GitLab and Gitea), never accepts the secret in the URL, deploys only pushes to the configured branch, ignores duplicate deliveries, starts at most one webhook deploy every 15 seconds, and never while another deployment is running.

Each deployment fetches the code into its own release folder and builds it as the hosting account, inside the package's CPU and memory limits. For server apps, the new release starts in the idle slot and must answer the Health check path with a status below 500 within 90 seconds before nginx switches over; if it fails, the old release keeps running and the site owner gets a Deployment failed notification. The Releases card keeps the last 5 builds, and Rollback is zero-downtime too.

Want GitHub Actions to run your tests first? With a Pro license, create an API token under My account → API tokens and trigger the deploy from the last step of your workflow:

curl -fsS -X POST \
  -H "Authorization: Bearer $ZOPANEL_TOKEN" \
  https://panel.example.com:8888/api/sites/<site-id>/app/deploy

Keep the token in GitHub Secrets and give it an expiry (30, 90 or 365 days). A Full token acts as your account, so treat it like a password. For worked examples, see deploying Django and Flask to a VPS and deploying a Node.js app to a VPS.

Common auto-deploy mistakes

  • git pull into the live directory: no rollback, and a mix of old and new files during the pull.
  • Deploying as root: one compromised build step (say, a malicious npm package) owns the whole server.
  • Secrets in the repository: a committed .env. Use environment variables on the server or CI secrets.
  • No health check: a release that cannot start still becomes the live one.
  • Overlapping deploys: two quick pushes overwrite each other. Always lock.
  • Backward-incompatible migrations: the new release changes the schema and the old one (or a rollback) breaks. Add columns first, drop them in a later release.

Conclusion

Auto deploying from GitHub saves time every day, but it is only trustworthy with authentication, isolated builds, health checks and rollback. You can build all of that yourself with GitHub Actions, a forced-command SSH key and a release script like the one above. If you would rather not maintain it for every project, a panel with Git deploy does it for you.

ZoPanel's Free plan includes Git deploy, webhooks and rollback for 10 websites; API tokens for deploying from CI are part of Pro. See pricing and the Node.js and Python apps docs.

Frequently asked questions

Should I use GitHub Actions or a webhook to auto deploy?

GitHub Actions suits you when you want tests to run before deploying and logs kept on GitHub. Webhooks are faster, but you must secure the endpoint yourself. Many teams combine them: Actions runs the tests and its last step triggers the deploy on the server.

How do I verify a GitHub webhook?

Set a secret when you create the webhook, then compute the HMAC-SHA256 of the request body with that secret on the server and compare it to the X-Hub-Signature-256 header with a constant-time comparison. Reject anything that does not match.

Are GitHub deploy keys safe?

Deploy keys are read-only by default and tied to a single repository, so a leaked key only exposes that repository's code. Do not grant write access unless the server really needs to push.

How do I roll back a bad deployment?

Build each release in its own folder and point a current symlink at the live one. Rolling back means pointing the symlink at the previous release and restarting the service. With ZoPanel, click Rollback in the Releases card.

Do I need to open a new port on the VPS for webhooks?

Usually not: a webhook endpoint runs behind nginx on port 443 like any other site. With ZoPanel, the Payload URL is on the panel domain at port 8888, which is already open.