Build an Auto-Deploy Webhook: No More SSH at 2 AM

Development tutorial - IT technology blog
Development tutorial - IT technology blog

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 → SettingsWebhooksAdd webhook. Fill in the following:

  • Payload URL: https://yourserver.com/webhook
  • Content type: application/json
  • Secret: A random string — must match the WEBHOOK_SECRET in 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.

Share: