One-Click GitHub Deploy: Webhooks, Build Scripts, and Health-Gated Releases

17 min read
github deploywebhookbuild pipelinedeployment automationnotifications

A one-click deploy system triggers a full deployment pipeline from a single action: pushing a tag, clicking a button, or merging a pull request. The system receives a webhook from GitHub, builds the application, runs health checks, and deploys to production without manual SSH access or command-line intervention. This guide builds the complete system from webhook receiver through health-gated release.

Teams with one-click deploy systems ship 4.2x more frequently than teams requiring manual deployment steps, according to the 2025 DORA Accelerate report. The median deployment time drops from 45 minutes (manual) to 3.5 minutes (automated), and deployment-related incidents decrease by 73%.

---

Architecture Overview

GitHub Push/Tag/Merge

|

v

Webhook (POST /webhook/github)

|

v

Verify Signature (HMAC-SHA256)

|

v

Build (Docker multi-stage)

|

v

Deploy to Staging

|

v

Health Gate (HTTP checks)

|

v

Promote to Production

|

v

Notify (Slack/Discord)

ComponentTechnologyPurpose

Webhook ReceiverNode.js + ExpressReceives GitHub events
Signature VerificationHMAC-SHA256Prevents unauthorized deploys
Build SystemDocker + shell scriptsCreates deployable artifacts
Health GateHTTP pollingValidates deployment health
NotificationSlack webhookAlerts team of deploy status

---

Step 1: Webhook Receiver (Node.js)

The webhook receiver listens for GitHub push events, verifies the HMAC signature, and triggers the deployment pipeline.

const express = require("express");

const crypto = require("crypto");

const { execSync, spawn } = require("child_process");

const app = express();

const WEBHOOK_SECRET = process.env.GITHUB_WEBHOOK_SECRET;

const DEPLOY_BRANCH = "main";

const DEPLOY_SCRIPT = "/opt/deploy/build-and-deploy.sh";

// Raw body for HMAC verification

app.use("/webhook/github", express.raw({ type: "application/json" }));

function verifySignature(payload, signature) {

if (!signature) return false;

const sig = Buffer.from(signature);

const hmac = crypto.createHmac("sha256", WEBHOOK_SECRET);

const digest = Buffer.from("sha256=" + hmac.update(payload).digest("hex"));

return sig.length === digest.length && crypto.timingSafeEqual(sig, digest);

}

app.post("/webhook/github", (req, res) => {

const signature = req.headers["x-hub-signature-256"];

if (!verifySignature(req.body, signature)) {

console.error("Invalid webhook signature");

return res.status(401).json({ error: "Invalid signature" });

}

const event = req.headers["x-github-event"];

const payload = JSON.parse(req.body.toString());

if (event !== "push") {

return res.status(200).json({ message: "Ignored event: " + event });

}

const branch = payload.ref.replace("refs/heads/", "");

if (branch !== DEPLOY_BRANCH) {

return res.status(200).json({ message: "Ignored branch: " + branch });

}

const commitSha = payload.after.substring(0, 7);

const author = payload.pusher.name;

console.log("Deploy triggered by " + author + " (commit: " + commitSha + ")");

// Trigger deploy asynchronously

const deploy = spawn("bash", [DEPLOY_SCRIPT, commitSha], {

detached: true,

stdio: ["ignore", "pipe", "pipe"]

});

deploy.stdout.on("data", (data) => console.log("[deploy] " + data.toString().trim()));

deploy.stderr.on("data", (data) => console.error("[deploy-err] " + data.toString().trim()));

deploy.on("close", (code) => {

if (code === 0) {

console.log("Deploy succeeded for commit " + commitSha);

notifySlack("Deploy succeeded: " + commitSha + " by " + author);

} else {

console.error("Deploy FAILED for commit " + commitSha + " (exit code: " + code + ")");

notifySlack("Deploy FAILED: " + commitSha + " by " + author);

}

});

res.status(202).json({ message: "Deploy started", commit: commitSha });

});

function notifySlack(message) {

const webhookUrl = process.env.SLACK_WEBHOOK_URL;

if (!webhookUrl) return;

fetch(webhookUrl, {

method: "POST",

headers: { "Content-Type": "application/json" },

body: JSON.stringify({ text: message })

}).catch(err => console.error("Slack notification failed:", err.message));

}

app.listen(4000, () => {

console.log("Webhook receiver listening on port 4000");

});

The `timingSafeEqual` function prevents timing attacks against the HMAC verification. Without it, an attacker could determine the secret byte-by-byte by measuring response times. The deploy process spawns detached so the webhook responds immediately with 202 while the build runs in the background.

---

Step 2: Build and Deploy Script

#!/bin/bash

<h1>/opt/deploy/build-and-deploy.sh</h1>

set -e

COMMIT_SHA=$1

APP_DIR="/opt/app"

REGISTRY="ghcr.io/my-org/my-app"

STAGING_PORT=3001

PRODUCTION_PORT=3000

LOG_FILE="/var/log/deploy/${COMMIT_SHA}.log"

mkdir -p /var/log/deploy

exec > >(tee -a "$LOG_FILE") 2>&1

echo "=== Deploy started: $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="

echo "Commit: $COMMIT_SHA"

<h1>Step 1: Pull latest code</h1>

cd "$APP_DIR"

git fetch origin main

git checkout $COMMIT_SHA

<h1>Step 2: Build Docker image</h1>

echo "Building Docker image..."

docker build -t ${REGISTRY}:${COMMIT_SHA} -t ${REGISTRY}:staging-latest .

<h1>Step 3: Deploy to staging</h1>

echo "Deploying to staging (port $STAGING_PORT)..."

docker stop app-staging 2>/dev/null || true

docker rm app-staging 2>/dev/null || true

docker run -d \

--name app-staging \

--restart unless-stopped \

-p ${STAGING_PORT}:3000 \

-e NODE_ENV=staging \

-e DATABASE_URL="${STAGING_DATABASE_URL}" \

${REGISTRY}:${COMMIT_SHA}

<h1>Step 4: Health gate</h1>

echo "Running health checks on staging..."

MAX_RETRIES=10

RETRY_DELAY=3

for i in $(seq 1 $MAX_RETRIES); do

if curl -sf http://localhost:${STAGING_PORT}/health > /dev/null; then

echo "Health check passed (attempt $i/$MAX_RETRIES)"

break

fi

if [ $i -eq $MAX_RETRIES ]; then

echo "Health check FAILED after $MAX_RETRIES attempts. Aborting."

docker stop app-staging

exit 1

fi

echo "Health check attempt $i/$MAX_RETRIES failed. Retrying in ${RETRY_DELAY}s..."

sleep $RETRY_DELAY

done

<h1>Step 5: Promote to production</h1>

echo "Promoting to production (port $PRODUCTION_PORT)..."

docker tag ${REGISTRY}:${COMMIT_SHA} ${REGISTRY}:production-latest

<h1>Save current production container ID for rollback</h1>

PREVIOUS_CONTAINER=$(docker ps -q --filter name=app-production)

docker stop app-production 2>/dev/null || true

docker rm app-production 2>/dev/null || true

docker run -d \

--name app-production \

--restart unless-stopped \

-p ${PRODUCTION_PORT}:3000 \

-e NODE_ENV=production \

-e DATABASE_URL="${PRODUCTION_DATABASE_URL}" \

${REGISTRY}:${COMMIT_SHA}

<h1>Step 6: Verify production health</h1>

sleep 5

if curl -sf http://localhost:${PRODUCTION_PORT}/health > /dev/null; then

echo "Production health check PASSED"

else

echo "Production health check FAILED. Rolling back."

docker stop app-production 2>/dev/null || true

docker rm app-production 2>/dev/null || true

docker run -d \

--name app-production \

--restart unless-stopped \

-p ${PRODUCTION_PORT}:3000 \

-e NODE_ENV=production \

-e DATABASE_URL="${PRODUCTION_DATABASE_URL}" \

${REGISTRY}:production-latest

exit 1

fi

echo "=== Deploy completed: $(date -u +%Y-%m-%dT%H:%M:%SZ) ==="

---

Step 3: Artifact Promotion

Artifact promotion means the exact Docker image tested in staging is the same image deployed to production. No rebuild, no recompilation, no chance of "it worked in staging" discrepancies.

The promotion flow:

  • Build produces image tagged with commit SHA: `my-app:abc1234`
  • Staging deploys `my-app:abc1234`
  • After health gate passes, the same image is retagged: `my-app:production-latest`
  • Production pulls `my-app:production-latest` (which is byte-identical to `my-app:abc1234`)
  • <h1>Promote staging image to production tag</h1>
    

    docker tag ${REGISTRY}:${COMMIT_SHA} ${REGISTRY}:production-latest

    <h1>If using a registry, push the production tag</h1>

    docker push ${REGISTRY}:production-latest

    <h1>Verify images are identical</h1>

    STAGING_DIGEST=$(docker inspect --format="{{.Id}}" ${REGISTRY}:${COMMIT_SHA})

    PROD_DIGEST=$(docker inspect --format="{{.Id}}" ${REGISTRY}:production-latest)

    echo "Staging digest: $STAGING_DIGEST"

    echo "Production digest: $PROD_DIGEST"

    [ "$STAGING_DIGEST" = "$PROD_DIGEST" ] && echo "Images match" || echo "ERROR: Digest mismatch"

    ---

    Step 4: Health-Gated Deployment

    The health gate prevents unhealthy builds from reaching production. It polls the staging health endpoint with retries and exponential backoff.

    #!/bin/bash
    

    <h1>health-gate.sh - Health check with exponential backoff</h1>

    URL=$1

    MAX_ATTEMPTS=${2:-10}

    INITIAL_DELAY=${3:-2}

    DELAY=$INITIAL_DELAY

    for i in $(seq 1 $MAX_ATTEMPTS); do

    HTTP_CODE=$(curl -s -o /dev/null -w "%{http_code}" "$URL")

    if [ "$HTTP_CODE" = "200" ]; then

    echo "Health check passed (HTTP $HTTP_CODE, attempt $i)"

    exit 0

    fi

    echo "Attempt $i: HTTP $HTTP_CODE (retrying in ${DELAY}s)"

    sleep $DELAY

    DELAY=$((DELAY * 2))

    if [ $DELAY -gt 60 ]; then

    DELAY=60

    fi

    done

    echo "Health check failed after $MAX_ATTEMPTS attempts"

    exit 1

    Usage: `./health-gate.sh http://localhost:3001/health 10 2`

    ---

    Step 5: GitHub Webhook Configuration

    Set up the webhook in your GitHub repository:

  • Go to Settings > Webhooks > Add webhook
  • Payload URL: `https://deploy.yourdomain.com/webhook/github`
  • Content type: `application/json`
  • Secret: Generate with `openssl rand -hex 32`
  • Events: Select "Just the push event"
  • Protect the webhook endpoint with nginx:

    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 webhook IPs

    # See: https://api.github.com/meta

    allow 140.82.112.0/20;

    allow 185.199.108.0/22;

    allow 192.30.252.0/22;

    deny all;

    location /webhook/github {

    proxy_pass http://127.0.0.1:4000;

    proxy_set_header Host $host;

    proxy_set_header X-Real-IP $remote_addr;

    proxy_read_timeout 30s;

    }

    }

    ---

    Rollback Procedure

    Every deployment logs the previous production image. To rollback:

    #!/bin/bash
    

    <h1>rollback.sh</h1>

    REGISTRY="ghcr.io/my-org/my-app"

    <h1>Find last known good image</h1>

    PREVIOUS_TAG=$(docker inspect app-production --format='{{.Config.Image}}' 2>/dev/null || echo "unknown")

    echo "Rolling back from current to previous version"

    <h1>Get the image before the current one from deploy logs</h1>

    ROLLBACK_IMAGE=$(grep "production-latest" /var/log/deploy/*.log

    tail -2head -1
    awk '{print $NF}')

    docker stop app-production 2>/dev/null || true

    docker rm app-production 2>/dev/null || true

    docker run -d \

    --name app-production \

    --restart unless-stopped \

    -p 3000:3000 \

    -e NODE_ENV=production \

    -e DATABASE_URL="${PRODUCTION_DATABASE_URL}" \

    ${ROLLBACK_IMAGE}

    echo "Rolled back to: ${ROLLBACK_IMAGE}"

    For teams that need more sophisticated deployment strategies, see our blue-green deployment checklist. To set up proper monitoring for your deployed application, read our VPS monitoring and alerting guide. If you are ready to move beyond VPS to Kubernetes, check our Docker Compose to Kubernetes migration guide.

    ---

    Frequently Asked Questions

    How do I prevent accidental deploys from feature branches?

    The webhook receiver filters events by branch name. Only pushes to the configured `DEPLOY_BRANCH` (typically `main`) trigger deployments. Pushes to feature branches receive a 200 response with an "ignored" message. For additional safety, use GitHub branch protection rules to require PR reviews before merging to main.

    What if the webhook receiver crashes during a deploy?

    The deploy process spawns detached from the webhook receiver, so it completes even if the receiver restarts. Run the webhook receiver under PM2 or systemd for automatic restart on crash. Deploy logs are written to `/var/log/deploy/` so you can audit completed deployments.

    How do I handle multiple simultaneous deploys?

    Add a lockfile to prevent concurrent deployments. At the start of `build-and-deploy.sh`, check for `/tmp/deploy.lock`. If it exists, queue the deploy or reject it. Release the lock at the end of the script or on error.

    LOCKFILE="/tmp/deploy.lock"
    

    if [ -f "$LOCKFILE" ]; then

    echo "Deploy already in progress. Aborting."

    exit 1

    fi

    echo $$ > "$LOCKFILE"

    trap "rm -f $LOCKFILE" EXIT

    Can I add a manual approval step?

    Yes. Instead of automatically promoting from staging to production, the deploy script can pause after the staging health gate and send a Slack message with approve/reject buttons. An API endpoint on the webhook receiver accepts the approval and triggers the production deploy. GitHub Environments also support required reviewers natively.

    How much server resources does the webhook receiver need?

    The webhook receiver itself uses minimal resources (under 50 MB RAM, negligible CPU). The Docker build is the resource-intensive step. On a 2 vCPU / 4 GB VPS, a typical Node.js Docker build completes in 90-180 seconds. Allocate at least 2 GB free RAM during builds to avoid OOM kills.

    How do I test the webhook locally?

    Use ngrok to expose your local webhook receiver to GitHub: `ngrok http 4000`. Set the ngrok URL as the webhook payload URL in GitHub. Push a test commit to verify the entire flow. Remove the ngrok webhook after testing.