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)
| Component | Technology | Purpose |
| Webhook Receiver | Node.js + Express | Receives GitHub events |
| Signature Verification | HMAC-SHA256 | Prevents unauthorized deploys |
| Build System | Docker + shell scripts | Creates deployable artifacts |
| Health Gate | HTTP polling | Validates deployment health |
| Notification | Slack webhook | Alerts 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:
<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:
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 -2 head -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.