How to Set Up Automatic Deployment from GitHub to Your VPS Using Webhooks

How to Set Up Automatic Deployment from GitHub to Your VPS Using Webhooks

A webhook-based deployment pipeline is one of the simplest and most satisfying automations you can build: push code to GitHub, and within seconds your VPS pulls the changes and restarts your application. No CI platform subscription, no complex configuration — just a lightweight webhook listener on your VPS that responds to GitHub push events.

This guide builds a complete automatic deployment system from scratch: a webhook receiver written in Node.js or Python, GitHub webhook configuration, security with HMAC signatures, and deployment scripts for common setups.

How Webhook Deployment Works

1. Developer pushes code to GitHub (main branch)
        │
        ▼
2. GitHub sends HTTP POST to your VPS webhook endpoint
   → POST https://deploy.yourdomain.com/webhook
   → Payload: commit info, branch, repo details
   → Header: X-Hub-Signature-256 (HMAC signature)
        │
        ▼
3. VPS webhook receiver validates the signature
        │
        ▼
4. VPS runs deployment script:
   → git pull origin main
   → npm install / pip install / composer install
   → Restart application (pm2 reload / systemctl restart)
        │
        ▼
5. Updated application is live — typically within 10–30 seconds

Why Webhooks Instead of GitHub Actions?

GitHub Webhooks GitHub Actions
Complexity Simple Moderate
GitHub Actions minutes Not used Consumed (free tier limit)
SSH key management Not needed Required (stored as secret)
Build environment VPS directly GitHub runner
Docker image builds ❌ (use Actions) ✅ Better
Simple git pull + restart ✅ Perfect Overkill

Webhooks are ideal for simple deployments: pull latest code, install dependencies, restart the app. For complex pipelines with Docker builds, test suites, and staging environments, GitHub Actions is the better choice.

💡 VPS.DO Tip: Webhook deployment works with any VPS.DO plan — the receiver uses minimal resources. View Plans →


Part 1: Node.js Webhook Receiver

Step 1: Set up the webhook directory

mkdir -p /var/webhook
cd /var/webhook
npm init -y
npm install express crypto

Step 2: Create the webhook server

nano /var/webhook/server.js
const express = require('express');
const crypto  = require('crypto');
const { exec } = require('child_process');
const app = express();

// ── Configuration ─────────────────────────────────────────
const PORT           = 9000;
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET || 'your-secret-here';

// Map: repo name → deployment script path
const DEPLOY_SCRIPTS = {
  'my-node-app':   '/var/deployments/nodeapp/deploy.sh',
  'my-python-app': '/var/deployments/pythonapp/deploy.sh',
  'my-blog':       '/var/deployments/blog/deploy.sh',
};

// ── Middleware ────────────────────────────────────────────
app.use(express.json({
  verify: (req, res, buf) => {
    req.rawBody = buf;  // Save raw body for signature verification
  }
}));

// ── Security: Verify GitHub HMAC signature ────────────────
function verifySignature(req) {
  const signature = req.headers['x-hub-signature-256'];
  if (!signature) return false;

  const hmac = crypto.createHmac('sha256', WEBHOOK_SECRET);
  const digest = 'sha256=' + hmac.update(req.rawBody).digest('hex');

  // Use timingSafeEqual to prevent timing attacks
  return crypto.timingSafeEqual(
    Buffer.from(signature),
    Buffer.from(digest)
  );
}

// ── Webhook endpoint ──────────────────────────────────────
app.post('/webhook', (req, res) => {
  // Verify signature
  if (!verifySignature(req)) {
    console.log(`⚠️  Invalid signature from ${req.ip}`);
    return res.status(401).send('Unauthorized');
  }

  const event = req.headers['x-github-event'];
  const payload = req.body;

  // Only trigger on push events to main branch
  if (event !== 'push') {
    return res.status(200).send(`Event ${event} ignored`);
  }

  if (payload.ref !== 'refs/heads/main') {
    return res.status(200).send(`Branch ${payload.ref} ignored`);
  }

  const repoName = payload.repository.name;
  const deployScript = DEPLOY_SCRIPTS[repoName];

  if (!deployScript) {
    console.log(`⚠️  No deploy script for repo: ${repoName}`);
    return res.status(200).send(`No deploy script for ${repoName}`);
  }

  console.log(`🚀 Deploying ${repoName} (commit: ${payload.after?.slice(0, 7)})`);

  // Run deployment script asynchronously
  exec(`bash ${deployScript}`, (error, stdout, stderr) => {
    if (error) {
      console.error(`❌ Deploy failed: ${error.message}`);
      console.error(stderr);
    } else {
      console.log(`✅ Deploy successful:\n${stdout}`);
    }
  });

  // Respond immediately — don't wait for deploy to finish
  res.status(200).send('Deployment triggered');
});

// ── Health check ──────────────────────────────────────────
app.get('/health', (req, res) => {
  res.json({ status: 'ok', timestamp: new Date().toISOString() });
});

app.listen(PORT, '127.0.0.1', () => {
  console.log(`Webhook server listening on port ${PORT}`);
});

Part 2: Create Deployment Scripts

Node.js application deploy script

mkdir -p /var/deployments/nodeapp
nano /var/deployments/nodeapp/deploy.sh
#!/bin/bash
set -e
APP_DIR="/var/www/my-node-app"
APP_NAME="my-node-app"
LOG="/var/log/deployments.log"

echo "=== Deploying $APP_NAME at $(date) ===" | tee -a $LOG

cd $APP_DIR

# Pull latest code
git pull origin main 2>&1 | tee -a $LOG

# Install/update dependencies
npm ci --only=production 2>&1 | tee -a $LOG

# Zero-downtime reload with PM2
pm2 reload $APP_NAME 2>&1 | tee -a $LOG

echo "=== Deploy complete ===" | tee -a $LOG

Static site (Hugo/Jekyll) deploy script

nano /var/deployments/blog/deploy.sh
#!/bin/bash
set -e
REPO_DIR="/var/repos/my-blog"
WEB_ROOT="/var/www/myblog.com"

echo "=== Deploying blog at $(date) ==="

cd $REPO_DIR
git pull origin main

# Build Hugo site
hugo --minify

# Deploy to web root
rsync -avz --delete ./public/ $WEB_ROOT/

echo "=== Blog deployed ==="

Python/Django deploy script

nano /var/deployments/pythonapp/deploy.sh
#!/bin/bash
set -e
APP_DIR="/var/www/my-python-app"

echo "=== Deploying Python app at $(date) ==="

cd $APP_DIR
source venv/bin/activate

git pull origin main
pip install -r requirements.txt --quiet
python manage.py migrate --noinput
python manage.py collectstatic --noinput

# Zero-downtime reload
sudo systemctl reload gunicorn

echo "=== Python deploy complete ==="
chmod +x /var/deployments/*/deploy.sh

Part 3: Run the Webhook Server with PM2

sudo npm install -g pm2

# Start the webhook server
pm2 start /var/webhook/server.js \
  --name "webhook" \
  --env WEBHOOK_SECRET="your-very-secure-secret-here"

pm2 startup
pm2 save

Part 4: Expose Webhook via Nginx

sudo nano /etc/nginx/sites-available/deploy.yourdomain.com
server {
    listen 80;
    server_name deploy.yourdomain.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name deploy.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/deploy.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/deploy.yourdomain.com/privkey.pem;

    # Only allow GitHub's IP ranges
    # (verify current ranges at https://api.github.com/meta)
    allow 192.30.252.0/22;
    allow 185.199.108.0/22;
    allow 140.82.112.0/20;
    allow 143.55.64.0/20;
    allow 2a0a:a440::/29;
    allow 2606:50c0::/32;
    deny all;

    location /webhook {
        proxy_pass http://127.0.0.1:9000/webhook;
        proxy_http_version 1.1;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}
sudo ln -s /etc/nginx/sites-available/deploy.yourdomain.com \
           /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx
sudo certbot --nginx -d deploy.yourdomain.com

Part 5: Configure GitHub Webhook

  1. Go to your GitHub repository → Settings → Webhooks → Add webhook
  2. Payload URL: https://deploy.yourdomain.com/webhook
  3. Content type: application/json
  4. Secret: The same secret you used in your webhook server (WEBHOOK_SECRET)
  5. Which events: Just the push event
  6. Check Active and click Add webhook

GitHub will immediately send a ping event. Check your VPS logs:

pm2 logs webhook

You should see the ping event received and processed. ✅


Part 6: Test the Deployment

# Make a change to your repository
echo "# Auto-deployment test" >> README.md
git add README.md
git commit -m "Test webhook deployment"
git push origin main

Watch the deployment happen in real time:

# Watch webhook server logs
pm2 logs webhook

# Watch deployment log
tail -f /var/log/deployments.log

# Watch your application logs
pm2 logs my-node-app

Within 10–30 seconds of the push, your VPS should have pulled the change and restarted your application. ✅


Part 7: Add Deployment Notifications

Get notified when deployments succeed or fail. Add to your webhook server:

// In server.js, add after successful deploy
const https = require('https');

function sendTelegramNotification(message) {
  const BOT_TOKEN = process.env.TELEGRAM_BOT_TOKEN;
  const CHAT_ID   = process.env.TELEGRAM_CHAT_ID;
  
  if (!BOT_TOKEN || !CHAT_ID) return;

  const url = `https://api.telegram.org/bot${BOT_TOKEN}/sendMessage`;
  const body = JSON.stringify({ chat_id: CHAT_ID, text: message });

  const options = {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' }
  };

  const req = https.request(url, options);
  req.write(body);
  req.end();
}

// Call in deploy callback:
exec(`bash ${deployScript}`, (error, stdout) => {
  if (error) {
    sendTelegramNotification(`❌ Deploy FAILED: ${repoName}\n${error.message}`);
  } else {
    sendTelegramNotification(`✅ Deployed: ${repoName} (${payload.after?.slice(0,7)})`);
  }
});

Troubleshooting

GitHub shows webhook delivery failed

Check that your webhook URL is accessible from the internet and returns 200. Use the GitHub webhook “Redeliver” button to retry and see the exact response.

Signature verification fails

The secret in GitHub webhook settings must exactly match WEBHOOK_SECRET in your server. No extra spaces or newlines.

Deploy script runs but changes don’t appear

The deploy script user may not have write permission to the app directory. Check ownership:

ls -la /var/www/my-node-app
# Should be owned by the user running the webhook server

PM2 process runs as different user than expected

# Check which user PM2 is running as
pm2 info webhook | grep "user"

Final Thoughts

Webhook-based deployment is one of the most satisfying automations to build — the first time you push a commit and watch your server update itself within seconds, it genuinely feels like magic. The setup takes about an hour and eliminates an entire category of manual work from your development workflow.

For teams that need more sophisticated pipelines (Docker builds, multi-environment deployments, parallel test runners), GitHub Actions is the natural evolution. But for individual developers and small teams deploying standard web applications, webhooks deliver everything you need with minimum complexity.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!