2 AM. A client messages you about a bug. You debug, push the fix to GitHub, then open a terminal: ssh root@server, then cd /var/www/app && git pull && npm install && pm2 restart app. Done by 3 AM, back to bed. Same thing the next night. This loop went on for months until I finally sat down and set up a webhook once and for all.
On a recent web app project with 5 developers, this workflow changed everything. From push to production update: under 60 seconds. No one sitting and watching. No one forgetting to deploy because they were half asleep.
How Webhooks Work
It’s straightforward: a webhook is an HTTP endpoint on your server. When you push code to GitHub, GitHub sends a POST request to that endpoint with information about the commit. Your server receives it, verifies it actually came from GitHub, then runs the deploy script.
GitHub Actions requires you to write YAML, configure runners, and manage secrets and environments. Jenkins is even heavier. Webhooks are neither — simple enough to set up in a morning, stable enough to run in production for months without touching again. If you’re already using GitHub Actions for more complex pipelines, check out this beginner’s guide to CI/CD with GitHub Actions to see how the two approaches compare.
Developer pushes code to GitHub
↓
GitHub sends POST → https://yourserver.com/webhook
↓
Webhook server verifies secret key
↓
Runs: git pull → install deps → restart service
↓
Production updated ✓
Hands-On: Building a Webhook from Scratch
Step 1: Write the Webhook Server in Python
I went with Python + Flask — lightweight, easy to debug at midnight, and nothing new to learn if you already know Python. Install Flask on your server first:
pip3 install flask
Create the file /var/www/webhook/webhook.py:
import hmac
import hashlib
import subprocess
import os
from flask import Flask, request, abort
app = Flask(__name__)
# Read from environment variable — do NOT hardcode this
WEBHOOK_SECRET = os.environ.get("WEBHOOK_SECRET", "").encode()
DEPLOY_SCRIPT = "/var/www/webhook/deploy.sh"
def verify_signature(payload_body, signature_header):
"""Verify the request actually came from GitHub, not a random source."""
if not signature_header:
return False
hash_object = hmac.new(WEBHOOK_SECRET, msg=payload_body, digestmod=hashlib.sha256)
expected = "sha256=" + hash_object.hexdigest()
return hmac.compare_digest(expected, signature_header)
@app.route("/webhook", methods=["POST"])
def webhook():
signature = request.headers.get("X-Hub-Signature-256")
if not verify_signature(request.data, signature):
abort(403)
payload = request.json
# Only deploy on pushes to main branch
if payload.get("ref") != "refs/heads/main":
return {"status": "skipped", "reason": "not main branch"}, 200
result = subprocess.run(
[DEPLOY_SCRIPT],
capture_output=True,
text=True,
timeout=120
)
if result.returncode != 0:
print(f"Deploy failed:\n{result.stderr}")
return {"status": "error", "message": result.stderr}, 500
return {"status": "success"}, 200
if __name__ == "__main__":
app.run(host="127.0.0.1", port=9000)
Step 2: Write the Deploy Script
Create /var/www/webhook/deploy.sh. This is where you put your actual deploy logic — git pull, install deps, restart the service:
#!/bin/bash
set -e # Stop immediately if any command fails
APP_DIR="/var/www/myapp"
LOG_FILE="/var/log/deploy.log"
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Starting deploy..." >> $LOG_FILE
cd $APP_DIR
git pull origin main >> $LOG_FILE 2>&1
npm install --production >> $LOG_FILE 2>&1
pm2 restart myapp >> $LOG_FILE 2>&1
echo "[$(date '+%Y-%m-%d %H:%M:%S')] Deploy completed ✓" >> $LOG_FILE
chmod +x /var/www/webhook/deploy.sh
Step 3: Run the Webhook Server with systemd
Don’t just run Flask with python webhook.py & and walk away — the process will die on server reboot, and you’ll waste time figuring out why deploys stopped working. Use systemd so it restarts automatically:
sudo nano /etc/systemd/system/webhook.service
[Unit]
Description=GitHub Webhook Server
After=network.target
[Service]
Type=simple
User=www-data
WorkingDirectory=/var/www/webhook
Environment="WEBHOOK_SECRET=your_secret_here"
ExecStart=/usr/bin/python3 /var/www/webhook/webhook.py
Restart=always
RestartSec=5
[Install]
WantedBy=multi-user.target
sudo systemctl enable webhook
sudo systemctl start webhook
sudo systemctl status webhook
Step 4: Expose via Nginx
The webhook server is listening on 127.0.0.1:9000. Add a location block to your Nginx config so GitHub can reach it. For TLS best practices on your Nginx setup, the Nginx TLS 1.3 configuration guide is worth a read:
location /webhook {
proxy_pass http://127.0.0.1:9000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
sudo nginx -t && sudo systemctl reload nginx
Step 5: Configure the GitHub Webhook
Go to your GitHub repo → Settings → Webhooks → Add webhook. Fill in the following:
- Payload URL:
https://yourserver.com/webhook - Content type:
application/json - Secret: A random string — must match the
WEBHOOK_SECRETin your systemd service - Events: Just the push event is all you need
To generate a secure secret — and to understand why HMAC-SHA256 is the right choice here, see this overview of hashing and data encoding with MD5, SHA-256, and Base64:
openssl rand -hex 32
Testing It Out
GitHub has a Redeliver button in the Webhooks tab to resend a test request — much better than pushing a throwaway commit just to check. Or push a small commit to main and watch the logs:
tail -f /var/log/deploy.log
See “Deploy completed ✓” and you’re done. If not, the systemd journal will usually point straight to the problem:
journalctl -u webhook -f
Common Errors and How to Fix Them
403 Forbidden — Secret key mismatch. Double-check that the WEBHOOK_SECRET in your systemd service matches the secret entered on GitHub. After fixing, reload:
sudo systemctl daemon-reload && sudo systemctl restart webhook
Permission denied on git pull — The www-data user doesn’t have access to the app directory. One command to fix it:
sudo chown -R www-data:www-data /var/www/myapp
Git prompts for a password — The server doesn’t have an SSH key configured with GitHub. The quick fix is to use a Personal Access Token directly in the remote URL:
git remote set-url origin https://[email protected]/username/repo.git
Deploy script timeout — The script is killed after 120 seconds by default. If your deploy takes longer — building a Docker image, running database migrations — increase the timeout value in subprocess.run() accordingly.
Wrapping Up
At its core, a webhook is just a small HTTP server waiting for a signal from GitHub and then running a script. Nothing complicated. But it breaks the SSH-pull-restart loop that has cost me — and probably you — more late nights than either of us can count.
When you need automatic rollbacks, multi-environment deploys, or Slack alerts on failure — that’s when GitHub Actions or a dedicated CI/CD platform makes sense. For a deeper dive into managing secrets like WEBHOOK_SECRET securely in production, see this guide to managing secrets and API keys with HashiCorp Vault. But for a project that needs to ship today: this approach is enough, minimal code, minimal things that can break.
