Every time you push to main, you want the code running on your server to update automatically. The full CI/CD setup — GitHub Actions, build servers, artifact storage — is powerful but overkill for a single VPS.
Here’s the simpler path: a webhook listener that receives push events from GitHub, runs your deploy script, and uses Oxmgr for zero-downtime restarts.
How It Works
git push origin main
│
GitHub receives push
│
GitHub sends POST to your server
│
Webhook server validates + runs deploy.sh
│
deploy.sh: git pull + build + oxmgr reload
│
Zero-downtime deploy complete Total time: ~30 seconds from push to live, with zero manual steps.
Prerequisites
- A VPS or server with Node.js installed
- Oxmgr managing your app processes
- A GitHub (or GitLab) repository
- Port 9000 open on your server firewall
Step 1: The Deploy Script
Before the webhook, get the deploy script working manually:
# /var/www/myapp/deploy.sh
#!/bin/bash
set -euo pipefail
APP_DIR="/var/www/myapp"
APP_NAME="api"
echo "=== Deploy started: $(date) ==="
cd "$APP_DIR"
# Pull latest code
git fetch origin main
git reset --hard origin/main
# Install dependencies if lockfile changed
if git diff HEAD@{1} HEAD --name-only 2>/dev/null | grep -q "package-lock.json"; then
echo "Installing dependencies..."
npm ci --omit=dev
fi
# Build
if [ -f "package.json" ] && jq -e '.scripts.build' package.json > /dev/null 2>&1; then
echo "Building..."
npm run build
fi
# Zero-downtime restart
echo "Reloading processes..."
oxmgr reload "$APP_NAME"
# Verify health
echo "Checking health..."
sleep 3
if curl -sf "http://localhost:3000/health" > /dev/null; then
echo "=== Deploy successful: $(date) ==="
else
echo "=== ERROR: Health check failed ==="
oxmgr status
exit 1
fi Make it executable and test it:
chmod +x /var/www/myapp/deploy.sh
/var/www/myapp/deploy.sh Make sure it completes without errors before adding the webhook.
Step 2: The Webhook Server
A webhook server listens for POST requests from GitHub and runs the deploy script when the signature is valid.
npm install express crypto // webhook-server.js
import express from 'express';
import crypto from 'node:crypto';
import { exec } from 'node:child_process';
import { promisify } from 'node:util';
const execAsync = promisify(exec);
const app = express();
const WEBHOOK_SECRET = process.env.WEBHOOK_SECRET;
const DEPLOY_SCRIPT = process.env.DEPLOY_SCRIPT || '/var/www/myapp/deploy.sh';
const BRANCH = process.env.DEPLOY_BRANCH || 'main';
const PORT = parseInt(process.env.PORT || '9000');
if (!WEBHOOK_SECRET) {
console.error('WEBHOOK_SECRET environment variable is required');
process.exit(1);
}
// Parse raw body for signature verification
app.use(express.raw({ type: 'application/json' }));
// Verify GitHub signature
function verifySignature(payload, signature) {
const expected = 'sha256=' + crypto
.createHmac('sha256', WEBHOOK_SECRET)
.update(payload)
.digest('hex');
// Constant-time comparison to prevent timing attacks
return crypto.timingSafeEqual(
Buffer.from(expected),
Buffer.from(signature)
);
}
// Track deploy state
let deploying = false;
let lastDeploy = null;
app.post('/webhook', async (req, res) => {
const signature = req.headers['x-hub-signature-256'];
// Verify it's really from GitHub
if (!signature || !verifySignature(req.body, signature)) {
console.warn('Invalid signature — rejecting webhook');
return res.status(401).json({ error: 'Invalid signature' });
}
const event = req.headers['x-github-event'];
const payload = JSON.parse(req.body.toString());
// Only process push events to the right branch
if (event !== 'push' || payload.ref !== `refs/heads/${BRANCH}`) {
return res.json({ status: 'ignored', event, ref: payload.ref });
}
// Only one deploy at a time
if (deploying) {
console.log('Deploy already in progress — queuing skipped');
return res.json({ status: 'skipped', reason: 'deploy in progress' });
}
const commit = payload.head_commit;
console.log(`Deploying commit: ${commit.id.slice(0, 8)} — ${commit.message}`);
// Respond immediately so GitHub doesn't timeout
res.json({ status: 'deploying', commit: commit.id.slice(0, 8) });
// Run deploy asynchronously
deploying = true;
try {
const { stdout, stderr } = await execAsync(DEPLOY_SCRIPT, {
timeout: 120_000, // 2-minute timeout
env: { ...process.env }
});
console.log(stdout);
if (stderr) console.error(stderr);
lastDeploy = {
commit: commit.id,
message: commit.message,
author: commit.author.name,
deployedAt: new Date().toISOString(),
status: 'success'
};
console.log(`Deploy complete: ${commit.id.slice(0, 8)}`);
} catch (err) {
console.error('Deploy failed:', err.message);
console.error(err.stdout);
console.error(err.stderr);
lastDeploy = {
commit: commit.id,
deployedAt: new Date().toISOString(),
status: 'failed',
error: err.message
};
} finally {
deploying = false;
}
});
// Status endpoint
app.get('/status', (req, res) => {
res.json({
deploying,
lastDeploy,
uptime: process.uptime()
});
});
app.listen(PORT, () => {
console.log(`Webhook server listening on :${PORT}`);
}); Step 3: Manage with Oxmgr
Add the webhook server to your oxfile.toml:
# Your main app
[processes.api]
command = "node dist/server.js"
instances = 2
restart_on_exit = true
restart_delay_ms = 1000
[processes.api.health_check]
endpoint = "http://localhost:3000/health"
interval_secs = 30
# Webhook server — always running, auto-restart
[processes.webhook]
command = "node webhook-server.js"
restart_on_exit = true
restart_delay_ms = 2000
[processes.webhook.env]
PORT = "9000"
WEBHOOK_SECRET = "your-webhook-secret-here"
DEPLOY_SCRIPT = "/var/www/myapp/deploy.sh"
DEPLOY_BRANCH = "main" Start everything:
oxmgr start The webhook server now runs alongside your app and restarts automatically if it crashes.
Step 4: GitHub Webhook Configuration
Go to your repository → Settings → Webhooks → Add webhook
Configure:
- Payload URL:
http://your-server-ip:9000/webhook - Content type:
application/json - Secret: Same value as
WEBHOOK_SECRETin your config - Which events:
Just the push event - Active: ✓
- Payload URL:
Click Add webhook
GitHub immediately sends a ping event. Check the Recent Deliveries tab to verify it reached your server (you’ll see a 200 response since we respond to all events).
Step 5: Secure with Nginx
Exposing port 9000 directly is fine, but routing through Nginx gives you TLS (HTTPS) and a cleaner URL:
# /etc/nginx/sites-available/myapp
server {
listen 443 ssl;
server_name your-domain.com;
# SSL config (certbot managed)
ssl_certificate /etc/letsencrypt/live/your-domain.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/your-domain.com/privkey.pem;
# Main app
location / {
proxy_pass http://localhost:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
# Webhook — separate path
location /webhook {
proxy_pass http://localhost:9000/webhook;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
location /deploy-status {
proxy_pass http://localhost:9000/status;
}
} Update your GitHub webhook URL to: https://your-domain.com/webhook
Testing the Full Flow
Push a change to main:
git commit -m "test: trigger webhook deploy"
git push origin main On your server, watch the webhook server logs:
oxmgr logs webhook -f You should see:
Deploying commit: a3f8b1c2 — test: trigger webhook deploy
=== Deploy started: Mon Apr 30 12:34:56 UTC 2026 ===
Building...
Reloading processes...
Checking health...
=== Deploy successful: Mon Apr 30 12:35:22 UTC 2026 ===
Deploy complete: a3f8b1c2 Check deploy status via the API:
curl https://your-domain.com/deploy-status | jq . {
"deploying": false,
"lastDeploy": {
"commit": "a3f8b1c2d3e4f5a6",
"message": "test: trigger webhook deploy",
"author": "Your Name",
"deployedAt": "2026-04-30T12:35:22.000Z",
"status": "success"
},
"uptime": 3600.23
} Handling Deploy Failures
When a deploy fails, you want to know immediately. Add Slack notification to the deploy script:
# In deploy.sh
SLACK_WEBHOOK="https://hooks.slack.com/services/YOUR/WEBHOOK/URL"
notify_slack() {
local status=$1
local message=$2
curl -s -X POST "$SLACK_WEBHOOK"
-H 'Content-type: application/json'
-d "{"text": "$status Deploy on $(hostname): $message"}"
}
# At the end of the script
if [ $? -eq 0 ]; then
notify_slack "✅" "Deploy successful"
else
notify_slack "🔴" "Deploy FAILED — check logs"
fi Rollback
Webhook deploys make rollback easy — just push a revert commit:
git revert HEAD --no-edit
git push origin main
# Webhook triggers, reverted code deploys automatically Or, for immediate rollback to a specific commit:
git reset --hard <previous-commit>
git push --force origin main For zero-downtime deploys and the rolling restart mechanism, see the zero-downtime deployment guide.
When to Upgrade to CI/CD
Webhook deploys work well until you need:
- Running tests before deploying
- Multiple environments (staging, production)
- Docker image building
- Deployment approval workflows
- Deployment history and audit trails
At that point, GitHub Actions or a dedicated CI/CD system is worth the overhead. Until then, a webhook + deploy script is reliable, fast, and has zero dependencies on external services.