deploy-receiver: webhook-triggered deploys that roll themselves back
What it is
A minimal, self-hosted webhook receiver that turns a GitHub push into a production deployment. If the new build is unhealthy, it automatically redeploys the last known-good commit.
The pipeline it orchestrates:
git pull → docker build → docker compose up → health check
└── fail? → rollback to last-good SHA
No CI runner, no Jenkins, no SaaS. One more container in your own compose stack.
What runs where
GitHub (push to prod)
│
│ HMAC-SHA256 signed webhook
▼
┌─────────────────────┐
│ nginx (TLS term) │ optional
└────────┬────────────┘
▼
┌─────────────────────┐
│ deploy-receiver │ Express, port 4000
│ POST /webhook ────│── verify signature → lock → 202 → deploy
│ GET /health ────│── liveness probe
└────────┬────────────┘
│ /var/run/docker.sock
▼
┌─────────────────────┐
│ Docker Engine │
│ build → compose up │
└────────┬────────────┘
▼
┌─────────────────────┐
│ Your App │ health OK → record last-good SHA
│ │ health FAIL → rollback to last-good SHA
└─────────────────────┘
Outputs:
/logs/deploy-audit.log — JSON-lines deploy history (rotated)
/logs/deploy-failures.log — human-readable failure log
Discord webhook — pass/fail notifications (optional)
How it works
Webhook endpoint (POST /webhook)
- Event type check.
pingresponds 200, and non-pushevents are ignored gracefully. - Signature verification. The HMAC-SHA256 comparison against
WEBHOOK_SECRETusescrypto.timingSafeEqual, which runs in constant time and prevents timing-oracle attacks. - Branch filter. Deploys happen only when
refmatchesrefs/heads/prod. - Lock acquisition. An atomic filesystem lock (
wxflag) prevents concurrent deploys. PID + timestamp checks detect stale locks. - Immediate 202 response. GitHub's webhook delivery times out after 10s, and a Docker build takes minutes. The receiver acks first and deploys asynchronously.
Deploy pipeline
git fetch origin prod && git checkout -B prod origin/prod
├── failure → abort, notify
docker build -t app:<sha> -t app:latest /repo
├── failure → abort, notify
docker compose up --no-build -d app
├── failure → abort, notify
health check (up to 6 attempts with backoff)
├── failure → rollback to last-good SHA → notify
success → record last-good SHA → audit log → notify
Filesystem lock
/tmp/deploy.lock is created with fs.writeFileSync(path, data, { flag: 'wx' }). "Write, fail if exists" is atomic at the OS level, so when two requests race, exactly one wins.
Two checks catch a stale lock. process.kill(pid, 0) checks the holder's PID and succeeds only if the process is alive. Locks older than LOCK_TIMEOUT_MINUTES (default 15) are reclaimed.
Why these choices
| Decision | Rationale |
|---|---|
| Custom receiver over CI/CD platforms | Lighter than Jenkins/GitLab runners for single-server compose deploys. No agent, no plugins, and it runs as one more container |
| Filesystem lock over a database | Zero external dependencies. wx gives OS-level atomicity. PID check handles crash recovery |
crypto.timingSafeEqual | Constant-time comparison defeats byte-by-byte timing side channels |
| 202 before deploy completes | Fits GitHub's 10-second webhook window. The real work runs async |
git + docker baked into the image | The receiver orchestrates deploys. One container, one responsibility |
Where it stands
Working as designed in production. The pipeline, rollback, and audit logging are complete. Optional Discord notifications cover pass/fail visibility.