↓ Skip to main content

Building a Webhook Fanout Service

Table of Contents

I keep my Docker stacks in a public GitHub repository, and every push to main fires a webhook. The problem is that the service which deploys those stacks, Dockhand, lives inside my Traefik network and treats each stack as a separate entity. That means Dockhand needs its own webhook per stack ID, and with a growing number of stacks that quickly becomes unmanageable. So I built a small service that receives one public webhook and fans it out to every stack. The result is webhook-fanout, a living solution I keep updating and using.

The Problem
#

My stacks live in the stacks repository. When I merge a pull request, GitHub sends a webhook, and I want that to trigger a redeploy of the affected services. Dockhand, which runs inside my Docker network behind Traefik, is the tool that applies those stacks. But Dockhand exposes a separate webhook endpoint for every stack, like /api/git/stacks/{id}/webhook.

That is the core mismatch. GitHub gives me one webhook per repository, but Dockhand wants one webhook per stack. I could configure a GitHub webhook for every stack, but that means maintaining a growing list of endpoints and secrets. I wanted a single public endpoint that receives the GitHub webhook and relays it to all the internal stack endpoints.

sequenceDiagram
participant GH as GitHub (Public)
box Private Docker Network
participant T as Traefik
participant F as webhook-fanout
participant D as Dockhand
end

GH->>T: POST /api/git/stacks/webhook
Note over GH,T: public internet
T->>F: forward request
Note over F: verify signature, debounce 5 min
F-->>GH: 202 Accepted
Note over F: after debounce window
par fan out to every stack
F->>D: POST /api/git/stacks/1/webhook
and
F->>D: POST /api/git/stacks/2/webhook
and
F->>D: POST /api/git/stacks/3/webhook
end

Why Go?
#

For a small network service like this, Go is the natural fit. It compiles to a single static binary, which means the release image can be built FROM scratch with no runtime dependencies at all. Its standard library already covers HTTP, HMAC, and JSON, and the concurrency model makes the debounce timer and parallel fanout trivial to express. Add a memory footprint of a few megabytes and cold starts measured in milliseconds, and it is hard to justify anything heavier for a service whose entire job is relaying HTTP requests.

Why Not stdlib?
#

My first instinct was to write this with Go’s net/http stdlib. But the moment you add signature verification, body limits, IP extraction, rate limiting, and structured logging, you have written hundreds of lines of scaffolding. That is exactly the boilerplate that echo and caarlos0/env already implement. Using them collapsed the whole service into three source files and roughly three hundred lines. Lightweight does not have to mean naive.

Configuration via Template
#

The interesting design decision is how the fanout targets are configured. Instead of defining an endpoint and a list of fanout targets explicitly, the service takes one URL template with a single {id} placeholder plus a comma-separated list of IDs.

WEBHOOK_SECRET="super-secret"
IDS="1,2,3"
TARGET_TEMPLATE="https://dockhand.internal/api/git/stacks/{id}/webhook"

From that template the service derives everything. The public listen path is even derived from the template path, minus the {id} segment. So the template above makes the service listen on POST /api/git/stacks/webhook, mirroring the internal API shape. That makes it a drop-in middleman: the public endpoint looks exactly like the internal one, just without the stack ID.

Debounce is a Feature
#

I use Renovate to keep my stacks up to date, and it opens a merge request for every dependency update. When I review and merge a few updates, several merge requests land within a couple of minutes, and each one fires a webhook. Each webhook would normally trigger a full redeploy, so without debouncing a small batch of updates would cause several unnecessary deploys.

The service solves this by debouncing incoming webhooks for five minutes. It keeps a single timer, resets it on every incoming webhook, and fires once the window closes. All the webhooks from a merge batch are bundled into a single deploy.

sequenceDiagram
participant GH as GitHub
participant F as webhook-fanout
participant D as Dockhand

GH->>F: webhook (merge request 1)
Note over F: reset timer to 5 min
GH->>F: webhook (merge request 2)
Note over F: reset timer to 5 min
GH->>F: webhook (merge request 3)
Note over F: reset timer to 5 min
Note over F: no new webhooks for 5 min
F->>D: single bundled deploy

Security is Not Optional
#

This endpoint triggers live deploys, so it is hardened. The service validates the GitHub-style X-Hub-Signature-256 header with HMAC-SHA256 and a constant-time compare, returning 401 on failure. The same secret re-signs the outbound payloads, so the internal targets need zero new authentication logic. A rate limiter caps requests at ten per second per client, and a body limit keeps payloads at one megabyte. The release image is a scratch image running as a non-root user. The WEBHOOK_SECRET is even scrubbed from the process environment after it is loaded.

One honest caveat: the service trusts the X-Forwarded-For header to determine the real IP, without a proxy allowlist. That is acceptable here because the IP is only used for logging and the health endpoint. The actual security rests on the HMAC signature, not on the IP.

The Health Endpoint
#

A health endpoint turned out to be surprisingly handy. It returns the resolved target URLs, the next scheduled deploy time, and the requester’s real IP. When something is not deploying, that is the first place I look. It requires the same secret, so it is not exposed to the public.

Interesting Lessons Along the Way
#

Real client IPs behind a proxy
#

The service sits behind Traefik, so without any extra work every incoming request would appear to come from Traefik’s internal IP. Echo ships with an IP extractor that reads the proxy header for you, so getting the real client IP is a single line in the server setup instead of manual header parsing.

e := echo.NewWithConfig(echo.Config{
	Logger:      logger,
	IPExtractor: echo.ExtractIPFromXFFHeader(),
})

From then on c.RealIP() returns the actual client. One caveat: this trusts the X-Forwarded-For header, so a client connecting directly could spoof it. That is acceptable here because the IP is only used for logging and the health endpoint — authentication relies on the HMAC signature, not on the source address.

Timezones in a scratch image
#

The release image is built FROM scratch, which contains nothing — no shell, no libc, and no timezone database. Usually that means the TZ environment variable silently stops working. The fix is a single blank import in main.go.

import (
	_ "time/tzdata"
)

This embeds the timezone database directly into the binary, so TZ="Europe/Berlin" works exactly as it would on a full system and deploy times log in my local time.