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
- Go to your GitHub repository → Settings → Webhooks → Add webhook
- Payload URL:
https://deploy.yourdomain.com/webhook - Content type:
application/json - Secret: The same secret you used in your webhook server (
WEBHOOK_SECRET) - Which events: Just the push event
- 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.