A small Go service that receives a webhook, debounces it, and fans it out to a list of target webhook URLs.
- A webhook arrives at the listen path derived from
TARGET_TEMPLATE— the template with its/{id}segment removed. - The payload is verified against the
X-Hub-Signature-256HMAC header. - The payload is scheduled for delivery after a debounce window (default
5m). New webhooks reset the timer. - When the timer fires, the payload is POSTed to every target URL concurrently, with retries on failure.
All configuration is via environment variables:
| Variable | Required | Default | Description |
|---|---|---|---|
WEBHOOK_SECRET |
yes | — | HMAC secret for webhook signatures and health |
IDS |
yes | — | Comma-separated stack IDs to fan out to (sorted) |
TARGET_TEMPLATE |
yes | — | Absolute URL template; {id} segment is stripped for listening, filled per target |
ACCEPT_REGEX |
no | — | Only fan out when the payload matches this regex (empty = accept everything) |
LOG_LEVEL |
no | info |
debug, info, warn, or error |
TZ |
no | UTC |
Timezone for log timestamps |
LISTEN_ADDR |
no | 0.0.0.0:8080 |
Address the HTTP server listens on |
DEBOUNCE |
no | 5m |
Debounce window before fan-out |
REQUEST_TIMEOUT |
no | 30s |
Timeout per target delivery attempt |
MAX_BODY_BYTES |
no | 1048576 |
Max webhook body size (1 MiB) |
MAX_ATTEMPTS |
no | 4 |
Delivery attempts per target (incl. first) |
RETRY_BACKOFF |
no | 500ms |
Initial backoff between attempts (doubles) |
SHUTDOWN_TIMEOUT |
no | 10s |
Graceful shutdown timeout |
RATE_LIMIT |
no | 10 |
Requests per second per IP |
MAX_CONCURRENT |
no | 4 |
Max concurrent target deliveries |
Durations use Go syntax (500ms, 15s, 5m).
ACCEPT_REGEX is an allowlist: when set, a webhook is only scheduled for fan-out if its raw body matches the regex. Non-matching payloads are acknowledged with 202 and {"ignored": true} but never scheduled. When unset or empty, every verified webhook is accepted.
This is useful when a provider fires multiple webhooks per change but only one should trigger a deploy. For example, GitHub sends a push webhook for the Renovate branch creation, the branch deletion after merge, and the merge to main — only the actual merge should deploy:
ACCEPT_REGEX='"head_commit":{"id":[^}]*"message":\s*"Merge pull request'The regex is matched against the raw JSON body (Go RE2 syntax). Note that a squash merge whose PR title does not start with Merge pull request will not match; anchor on "ref": "refs/heads/main" instead if you use custom merge titles.
The listen path is TARGET_TEMPLATE's URL path with the /{id} segment removed:
TARGET_TEMPLATE |
Listens on |
|---|---|
https://hooks.example.com/api/git/stacks/{id}/webhook |
POST /api/git/stacks/webhook |
https://hooks.example.com/webhook/{id} |
POST /webhook |
Accepts the webhook payload. Requires a valid X-Hub-Signature-256 header (GitHub-style HMAC-SHA256 of the body using WEBHOOK_SECRET).
Returns 202 Accepted with the scheduled deploy time.
Health and status endpoint. Requires the secret as a bearer token:
curl -H "Authorization: Bearer $WEBHOOK_SECRET" http://localhost:8080/healthReturns the service status, the remote IP, the list of target URLs, and the next deploy time (null when no deploy is pending).
With TARGET_TEMPLATE=https://dockhand.example.com/api/git/stacks/{id}/webhook and IDS=42,43:
- Listens on
POST /api/git/stacks/webhook - Delivers to
https://dockhand.example.com/api/git/stacks/42/webhookandhttps://dockhand.example.com/api/git/stacks/43/webhook
With TARGET_TEMPLATE=https://dockhand.example.com/webhook/{id} and IDS=a,b:
- Listens on
POST /webhook - Delivers to
https://dockhand.example.com/webhook/aandhttps://dockhand.example.com/webhook/b
The derived listen path is logged at startup ("listen" attribute).
cp .env.example .env
docker compose up --buildThe dev service uses air for hot reload.
docker compose --profile build build releaseThe release image is a scratch-based, non-root binary. The GitHub Actions workflow builds and pushes the image to GHCR as :latest on every push to the default branch.