Backend / infra engineer

deploy-receiver: self-hosted auto-rollback deploys

Self-hosted deploy receiver for Docker Compose. Verifies signed GitHub webhooks (HMAC-SHA256, constant-time comparison) and orchestrates a git → Docker build → compose up → health-check pipeline that automatically rolls back to the last known-good commit on failure.

Year
2026
Stack
Node.jsExpressDocker ComposeGitHMAC-SHA256Linux
GitHub ↗

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)

  1. Event type check. ping responds 200, and non-push events are ignored gracefully.
  2. Signature verification. The HMAC-SHA256 comparison against WEBHOOK_SECRET uses crypto.timingSafeEqual, which runs in constant time and prevents timing-oracle attacks.
  3. Branch filter. Deploys happen only when ref matches refs/heads/prod.
  4. Lock acquisition. An atomic filesystem lock (wx flag) prevents concurrent deploys. PID + timestamp checks detect stale locks.
  5. 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

DecisionRationale
Custom receiver over CI/CD platformsLighter than Jenkins/GitLab runners for single-server compose deploys. No agent, no plugins, and it runs as one more container
Filesystem lock over a databaseZero external dependencies. wx gives OS-level atomicity. PID check handles crash recovery
crypto.timingSafeEqualConstant-time comparison defeats byte-by-byte timing side channels
202 before deploy completesFits GitHub's 10-second webhook window. The real work runs async
git + docker baked into the imageThe 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.